diff --git a/src/impl/dash/coin/embedded_gbt.hpp b/src/impl/dash/coin/embedded_gbt.hpp index b60d59b06..9fcea2c17 100644 --- a/src/impl/dash/coin/embedded_gbt.hpp +++ b/src/impl/dash/coin/embedded_gbt.hpp @@ -214,6 +214,11 @@ inline DashWorkData build_embedded_workdata( pp.payee = "!" + hex_script; } pp.amount = static_cast(mn_payment); + // Byte-faithful fallback (bad-cb-payee hardening, mirrors the RPC + // arm): carry the raw scriptPayout bytes so the coinbase builder + // can emit them verbatim if the derived address ever fails to + // round-trip under the active net params. + pp.script.assign(script.begin(), script.end()); w.m_packed_payments.push_back(std::move(pp)); } } diff --git a/src/impl/dash/coin/rpc.cpp b/src/impl/dash/coin/rpc.cpp index 48ff5c52a..4fec2c3d4 100644 --- a/src/impl/dash/coin/rpc.cpp +++ b/src/impl/dash/coin/rpc.cpp @@ -205,7 +205,8 @@ bool NodeRPC::check() { uint256 genesis = uint256S(dash_genesis_hash(IS_TESTNET)); bool has_block = check_blockheader(genesis); - bool is_main_chain = getblockchaininfo()["chain"].get() == "main"; + const std::string chain = getblockchaininfo()["chain"].get(); + bool is_main_chain = chain == "main"; if (is_main_chain && !has_block) { @@ -213,6 +214,33 @@ bool NodeRPC::check() return false; } + // Chain-identity guard (bad-cb-payee root-cause hardening): a net-mode / + // daemon-chain mismatch previously passed this probe silently in BOTH + // wrong directions except mainnet-vs-mainnet-genesis-missing. The lethal + // one is a MAINNET-mode binary against a testnet daemon: every GBT + // masternode payee ('y...') fails base58 decode under mainnet + // address_version=76, the required payee output vanished from the built + // coinbase, and every won block was rejected bad-cb-payee (hex-confirmed + // against dashd getblocktemplate proposal mode). Fail LOUDLY at connect + // instead. --testnet accepts test/regtest/devnet (the tuned-net + G3b + // harness posture) but never "main". + if (!IS_TESTNET && !is_main_chain) + { + LOG_ERROR << "Chain mismatch: dashd reports chain='" << chain + << "' but c2pool-dash is in MAINNET mode. A mainnet-mode " + "coinbase drops the daemon's masternode payee outputs " + "(bad-cb-payee -- every won block would be lost). " + "Run with --testnet or point --coin-rpc at a mainnet dashd."; + return false; + } + if (IS_TESTNET && is_main_chain) + { + LOG_ERROR << "Chain mismatch: dashd reports chain='main' but " + "c2pool-dash is in TESTNET mode (--testnet). Drop " + "--testnet or point --coin-rpc at a testnet dashd."; + return false; + } + try { auto networkinfo = getnetworkinfo(); diff --git a/src/impl/dash/coin/rpc_data.hpp b/src/impl/dash/coin/rpc_data.hpp index 0f2abc89c..4573df043 100644 --- a/src/impl/dash/coin/rpc_data.hpp +++ b/src/impl/dash/coin/rpc_data.hpp @@ -21,9 +21,20 @@ namespace coin // One masternode / superblock / platform payment entry after normalization. // payee == "!" + hex_script when the payment is a raw script (OP_RETURN // platform payment); otherwise payee is a base58 address. +// +// script carries dashd's GBT-provided scriptPubKey bytes VERBATIM whenever the +// entry had a "script" field — even when the base58 payee wins the payee +// string. It is the byte-faithful fallback for the coinbase lane: if the payee +// string later fails to decode (wrong-net address version, future address +// type), the coinbase builder emits these exact bytes instead of silently +// dropping a consensus-REQUIRED output (bad-cb-payee => every won block lost). +// The payee STRING semantics are unchanged (share-wire compatible — the +// sharechain PackedPayment in share_types.hpp is a distinct struct and still +// carries only the string). struct PackedPayment { std::string payee; uint64_t amount{0}; + std::vector script; // raw GBT scriptPubKey (may be empty) }; // Normalize ONE masternode / superblock / platform payment entry from a dashd @@ -49,6 +60,32 @@ inline PackedPayment normalize_payment(const nlohmann::json& entry) pp.payee = "!" + entry["script"].get(); if (entry.contains("amount")) pp.amount = entry["amount"].get(); + // ALWAYS preserve the raw GBT scriptPubKey bytes alongside the payee + // string (bad-cb-payee hardening): dashd hands us the exact script it + // will enforce at ConnectBlock; keeping it means the coinbase builder + // never has to silently drop a consensus-required output just because + // the base58 round-trip failed. Manual hex decode keeps this header + // dependency-light (nlohmann-only, mirrors decode_payee_script). + if (entry.contains("script") && entry["script"].is_string()) + { + const std::string hex = entry["script"].get(); + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + std::vector raw; + raw.reserve(hex.size() / 2); + bool ok = (hex.size() % 2 == 0); + for (size_t i = 0; ok && i + 1 < hex.size(); i += 2) { + const int hi = nib(hex[i]), lo = nib(hex[i + 1]); + if (hi < 0 || lo < 0) { ok = false; break; } + raw.push_back(static_cast((hi << 4) | lo)); + } + if (ok) + pp.script = std::move(raw); + } } return pp; } diff --git a/src/impl/dash/coinbase_builder.hpp b/src/impl/dash/coinbase_builder.hpp index f4dfe9f47..e68b12695 100644 --- a/src/impl/dash/coinbase_builder.hpp +++ b/src/impl/dash/coinbase_builder.hpp @@ -91,7 +91,24 @@ inline std::vector compute_dash_payouts( const Script donation_script(dash::DONATION_SCRIPT.begin(), dash::DONATION_SCRIPT.end()); - // 1. payments_tx: preserves GBT order, drops zero-value entries + undecodable payees. + // 1. payments_tx: preserves GBT order, drops zero-value entries. + // + // bad-cb-payee HARD RULE: every non-zero packed payment is a + // consensus-REQUIRED coinbase output (masternode / superblock / platform + // burn — dashd's ConnectBlock enforces payee+amount). A coinbase missing + // one is guaranteed-rejected, so a won block would be silently lost. + // Resolution order per entry: + // (a) decode the payee string ("!"+hex raw script, or base58 address + // under the CURRENT net params) — the share-wire-compatible form; + // (b) if that fails, fall back to the GBT-provided raw scriptPubKey + // bytes VERBATIM (rpc_data.hpp normalize_payment preserves them) — + // byte-faithful to what dashd will enforce, LOUD warning (the + // classic trigger is a wrong-net binary: mainnet address_version + // applied to a testnet payee); + // (c) if NEITHER is available, THROW — refuse to build a doomed + // coinbase (the stratum path serves no job and screams, instead of + // letting a miner burn hashrate on a block dashd must reject). + // NEVER silently drop. std::vector payments_tx; payments_tx.reserve(packed_payments.size()); uint64_t total_payments = 0; @@ -99,7 +116,26 @@ inline std::vector compute_dash_payouts( if (p.amount == 0) continue; auto pm_script = dash::decode_payee_script( p.payee, params.address_version, params.address_p2sh_version); - if (pm_script.empty()) continue; + if (pm_script.empty() && !p.script.empty()) { + pm_script = p.script; // byte-faithful GBT scriptPubKey fallback + LOG_WARNING << "[compute_dash_payouts] payee '" << p.payee + << "' does not decode under address_version=" + << static_cast(params.address_version) + << "/p2sh=" << static_cast(params.address_p2sh_version) + << " -- using the GBT-provided scriptPubKey verbatim (" + << p.script.size() << "B). Check the --testnet flag " + "matches the daemon's chain."; + } + if (pm_script.empty()) { + // (c): no usable script AT ALL. A coinbase without this output is + // guaranteed bad-cb-payee => lost block reward. Refuse to build. + throw std::runtime_error( + "compute_dash_payouts: GBT payment payee '" + p.payee + + "' (amount=" + std::to_string(p.amount) + + ") is undecodable and carries no raw script -- refusing to " + "build a coinbase missing a consensus-required payee output " + "(bad-cb-payee)"); + } MinerPayout out; out.amount = p.amount; out.script = std::move(pm_script); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f164f36f0..9307898f7 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -945,7 +945,7 @@ if (BUILD_TESTING AND GTest_FOUND) add_executable(test_dash_cb_payee test_dash_cb_payee.cpp) target_link_libraries(test_dash_cb_payee PRIVATE GTest::gtest_main GTest::gtest - core + dash_x11 core nlohmann_json::nlohmann_json ${Boost_LIBRARIES} ) diff --git a/test/test_dash_cb_payee.cpp b/test/test_dash_cb_payee.cpp index 51f8a6855..548244558 100644 --- a/test/test_dash_cb_payee.cpp +++ b/test/test_dash_cb_payee.cpp @@ -87,4 +87,167 @@ TEST(DashCbPayee, NonObjectEntryYieldsEmptyPayment) { PackedPayment pp = normalize_payment(entry); EXPECT_TRUE(pp.payee.empty()); EXPECT_EQ(pp.amount, 0u); -} \ No newline at end of file +} +// ═══════════════════════════════════════════════════════════════════════════ +// bad-cb-payee round 2 (2026-07-19): the stratum-served coinbase LOST the +// masternode payee output whenever the GBT payee string failed base58 decode +// under the active net params — compute_dash_payouts silently `continue`d on +// an undecodable payee, and normalize_payment had discarded the GBT "script" +// bytes whenever a non-empty base58 payee was present, so there was no +// byte-faithful fallback. Reproduced live against testnet dashd +// (getblocktemplate proposal mode): a MAINNET-mode binary against the testnet +// daemon served a coinbase missing the 'y...' masternode output -> verdict +// "bad-cb-payee"; the same template under testnet params -> verdict null +// (valid). Fix: (a) normalize_payment ALWAYS preserves the raw GBT +// scriptPubKey bytes, (b) compute_dash_payouts falls back to them verbatim +// when the payee string does not decode, and (c) THROWS (never silently +// drops) when neither form is usable. These KATs pin all three arms plus a +// golden full-coinbase vector dashd accepted on live testnet. +// ═══════════════════════════════════════════════════════════════════════════ + +#include // compute_dash_payouts, build +#include // dash::make_coin_params +#include // ParseHex, HexStr + +#include +#include +#include +#include + +namespace { + +// The REAL dashd testnet GBT masternode array captured 2026-07-19 from +// 192.168.86.52:19998 (height 1517409 template): platform credit-pool burn + +// the round's masternode payee, exactly as dashd serialized them. +json real_testnet_masternode_entries() +{ + return json::array({ + {{"payee", ""}, + {"script", "6a"}, + {"amount", 66966830}}, + {{"payee", "yVXDAM73Tg6A44Bm3qduXsMCYxzuqBCT48"}, + {"script", "76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac"}, + {"amount", 111611384}}, + }); +} + +std::vector normalized_real_payments() +{ + std::vector out; + for (const auto& e : real_testnet_masternode_entries()) + out.push_back(normalize_payment(e)); + return out; +} + +bool payments_contain(const std::vector& outs, + const std::string& script_hex, uint64_t amount) +{ + const auto want = ParseHex(script_hex); + for (const auto& o : outs) + if (o.amount == amount + && o.script == std::vector(want.begin(), want.end())) + return true; + return false; +} + +} // namespace + +// (a) The raw GBT scriptPubKey bytes survive normalization ALONGSIDE a +// non-empty base58 payee (previously they were discarded on that branch). +TEST(DashCbPayee, NormalizePreservesRawScriptAlongsideBase58Payee) { + auto pp = normalize_payment(real_testnet_masternode_entries()[1]); + EXPECT_EQ(pp.payee, "yVXDAM73Tg6A44Bm3qduXsMCYxzuqBCT48"); + EXPECT_EQ(pp.amount, 111611384u); + const auto want = ParseHex("76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac"); + EXPECT_EQ(pp.script, std::vector(want.begin(), want.end())); +} + +// Right-net (testnet) params: both consensus-required payments present, payee +// decoded from the base58 string (share-wire form), byte-identical to the GBT +// script. +TEST(DashCbPayee, ComputePayoutsKeepsMasternodeOutputsOnRightNet) { + const auto params = dash::make_coin_params(/*testnet=*/true); + std::map, uint64_t> no_weights; + auto outs = dash::coinbase::compute_dash_payouts( + /*subsidy=*/238104286ULL, normalized_real_payments(), + /*miner_pubkey_hash=*/uint160(), no_weights, /*total_weight=*/0, params); + EXPECT_TRUE(payments_contain(outs, "6a", 66966830u)); + EXPECT_TRUE(payments_contain( + outs, "76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac", 111611384u)); +} + +// (b) THE regression: wrong-net (mainnet) params make the 'y...' testnet payee +// undecodable — pre-fix the masternode output silently vanished from the +// coinbase (live dashd verdict: bad-cb-payee). Post-fix the GBT-provided raw +// scriptPubKey is emitted VERBATIM, so the consensus-required output survives. +TEST(DashCbPayee, ComputePayoutsFallsBackToGbtScriptOnWrongNetParams) { + const auto params = dash::make_coin_params(/*testnet=*/false); + std::map, uint64_t> no_weights; + auto outs = dash::coinbase::compute_dash_payouts( + /*subsidy=*/238104286ULL, normalized_real_payments(), + /*miner_pubkey_hash=*/uint160(), no_weights, /*total_weight=*/0, params); + EXPECT_TRUE(payments_contain(outs, "6a", 66966830u)); + EXPECT_TRUE(payments_contain( + outs, "76a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac", 111611384u)) + << "masternode payee output missing under wrong-net params -- " + "bad-cb-payee regression"; +} + +// (c) Neither form usable -> THROW. A silently-dropped required payment is a +// guaranteed bad-cb-payee reject (lost block); the builder must refuse. +TEST(DashCbPayee, ComputePayoutsThrowsOnUndecodablePayeeWithoutScript) { + dash::coin::PackedPayment broken; + broken.payee = "not-a-decodable-payee"; + broken.amount = 12345; + const auto params = dash::make_coin_params(/*testnet=*/true); + std::map, uint64_t> no_weights; + EXPECT_THROW( + dash::coinbase::compute_dash_payouts( + /*subsidy=*/238104286ULL, {broken}, uint160(), no_weights, 0, params), + std::runtime_error); +} + +// GOLDEN (live-accepted): the full coinbase c2pool-dash served for the real +// height-1517409 testnet template, byte-for-byte. This exact serialization +// (zeroed nonce64 slot) was submitted to dashd 192.168.86.52 via +// getblocktemplate proposal mode on 2026-07-19 and ACCEPTED (verdict null): +// worker/finder output, platform OP_RETURN burn, masternode payee, donation +// tail, c2pool OP_RETURN ref+nonce64 slot, and the DIP4 CbTx extra_payload. +TEST(DashCbPayee, GoldenLiveAcceptedTestnetCoinbase) { + dash::coin::DashWorkData work; + work.m_height = 1517409; + work.m_coinbase_value = 238104286ULL; + work.m_packed_payments = normalized_real_payments(); + const auto payload = ParseHex( + "0300612717008fd43916c4062570542a746a18808d00bb47107d541c0eeb0defc107fd" + "07bc6b7f6665a24049a71e2a3460ab19a23b3169d71f4eb42448b4589ccf138be493ec" + "01b8c52ecdcb1f335749e9974eef630753dd0496c3e59b3e0362c5776284f0b5190cb9" + "37134903a9fce51754bda7dd04da09022ef0b6abe378260d5982f4048cb9344dbac1d6" + "a82866ec2f679f243ce8e65268e2711f4026bef8034dfb6d264afbb70b659cd21e0000"); + work.m_coinbase_payload.assign(payload.begin(), payload.end()); + + const auto params = dash::make_coin_params(/*testnet=*/true); + std::map, uint64_t> no_weights; + auto outs = dash::coinbase::compute_dash_payouts( + work.m_coinbase_value, work.m_packed_payments, + /*miner_pubkey_hash=*/uint160(), no_weights, /*total_weight=*/0, params); + auto layout = dash::coinbase::build(work, outs, /*pool_tag=*/"c2pool", + params, /*ref_hash=*/uint256::ZERO); + + const std::string expected = + "03000500010000000000000000000000000000000000000000000000000000000000" + "000000ffffffff0a036127176332706f6f6cffffffff05792a1200000000001976a9" + "14000000000000000000000000000000000000000088ac2ed5fd0300000000016af8" + "0da706000000001976a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac3f" + "217a03000000001976a91420cb5c22b1e4d5947e5c112c7696b51ad9af3c6188ac00" + "000000000000002a6a28000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000af0300612717008fd43916c40625" + "70542a746a18808d00bb47107d541c0eeb0defc107fd07bc6b7f6665a24049a71e2a" + "3460ab19a23b3169d71f4eb42448b4589ccf138be493ec01b8c52ecdcb1f335749e9" + "974eef630753dd0496c3e59b3e0362c5776284f0b5190cb937134903a9fce51754bd" + "a7dd04da09022ef0b6abe378260d5982f4048cb9344dbac1d6a82866ec2f679f243c" + "e8e65268e2711f4026bef8034dfb6d264afbb70b659cd21e0000"; + EXPECT_EQ(HexStr(std::span( + layout.bytes.data(), layout.bytes.size())), + expected); +} diff --git a/test/test_dash_coinbase_parity.cpp b/test/test_dash_coinbase_parity.cpp index 77ed347ca..c85125ef3 100644 --- a/test/test_dash_coinbase_parity.cpp +++ b/test/test_dash_coinbase_parity.cpp @@ -133,15 +133,18 @@ TEST(DashCoinbaseParity, TxOutOnWireBytesMatchOracle) { } // (8) compute_dash_payouts tx_out ORDER == oracle generate_transaction: -// worker_tx (finder) || payments_tx (GBT order, nonzero+decodable only) || -// donation_tx (always last). Drops zero-amount + "script:"/undecodable payees. +// worker_tx (finder) || payments_tx (GBT order, nonzero only) || +// donation_tx (always last). Drops zero-amount entries ONLY — a non-zero +// undecodable payee now THROWS instead of being silently dropped +// (bad-cb-payee hard rule, 2026-07-19; see test (8b) below): a coinbase +// missing a consensus-required payment is guaranteed-rejected by dashd, +// so the builder must refuse rather than build a doomed coinbase. TEST(DashCoinbaseParity, TxOutOrderingWorkerPaymentsDonation) { auto params = dash::make_coin_params(true); // testnet: ver 140 / p2sh 19 std::vector payments; payments.push_back({kAddrP2PKH, 5000}); // valid base58 P2PKH -> kept payments.push_back({"!6a04deadbeef", 1}); // valid raw script -> kept - payments.push_back({"script:76a914aa88ac", 9}); // legacy form -> dropped payments.push_back({kAddrP2SH, 0}); // zero amount -> dropped // Distinct finder hash so its script never coalesces with a payee script. @@ -168,6 +171,28 @@ TEST(DashCoinbaseParity, TxOutOrderingWorkerPaymentsDonation) { EXPECT_EQ(sum, subsidy); } +// (8b) bad-cb-payee hard rule: a NON-ZERO payment whose payee cannot be +// decoded (legacy "script:" form, corrupt string) and which carries no +// raw GBT script bytes must THROW — never be silently dropped. The +// pre-fix silent drop built a coinbase missing a consensus-required +// output; dashd rejected the won block with bad-cb-payee (hex-confirmed +// live on testnet 2026-07-19). +TEST(DashCoinbaseParity, UndecodablePayeeThrowsNeverDrops) { + auto params = dash::make_coin_params(true); + + std::vector payments; + payments.push_back({"script:76a914aa88ac", 9}); // legacy form, no raw script + + std::vector finder_h(20, 0x07); + uint160 finder(finder_h); + + EXPECT_THROW( + dash::coinbase::compute_dash_payouts( + 5000000000ull, payments, finder, /*weights=*/{}, + /*total_weight=*/0, params), + std::runtime_error); +} + // (9) v36 ARM: full-weight split, NO 2% block-finder fee, donation = remainder. // Gated on params.current_share_version >= 36 (core::version_gate SSOT). // Mirrors DGB share_tracker::get_expected_payouts; total_weight is the