Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/impl/dash/coin/embedded_gbt.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ inline DashWorkData build_embedded_workdata(
pp.payee = "!" + hex_script;
}
pp.amount = static_cast<uint64_t>(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));
}
}
Expand Down
30 changes: 29 additions & 1 deletion src/impl/dash/coin/rpc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,42 @@ bool NodeRPC::check()
{
uint256 genesis = uint256S(dash_genesis_hash(IS_TESTNET));
bool has_block = check_blockheader(genesis);
bool is_main_chain = getblockchaininfo()["chain"].get<std::string>() == "main";
const std::string chain = getblockchaininfo()["chain"].get<std::string>();
bool is_main_chain = chain == "main";

if (is_main_chain && !has_block)
{
LOG_ERROR << "Check failed! Make sure that you're connected to the right dashd with --dash-rpc-port, and that it has finished syncing!" << std::endl;
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();
Expand Down
37 changes: 37 additions & 0 deletions src/impl/dash/coin/rpc_data.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<unsigned char> script; // raw GBT scriptPubKey (may be empty)
};

// Normalize ONE masternode / superblock / platform payment entry from a dashd
Expand All @@ -49,6 +60,32 @@ inline PackedPayment normalize_payment(const nlohmann::json& entry)
pp.payee = "!" + entry["script"].get<std::string>();
if (entry.contains("amount"))
pp.amount = entry["amount"].get<uint64_t>();
// 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<std::string>();
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<unsigned char> 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<unsigned char>((hi << 4) | lo));
}
if (ok)
pp.script = std::move(raw);
}
}
return pp;
}
Expand Down
40 changes: 38 additions & 2 deletions src/impl/dash/coinbase_builder.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,51 @@ inline std::vector<MinerPayout> 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<MinerPayout> payments_tx;
payments_tx.reserve(packed_payments.size());
uint64_t total_payments = 0;
for (const auto& p : packed_payments) {
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<int>(params.address_version)
<< "/p2sh=" << static_cast<int>(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);
Expand Down
2 changes: 1 addition & 1 deletion test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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}
)
Expand Down
165 changes: 164 additions & 1 deletion test/test_dash_cb_payee.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,167 @@ TEST(DashCbPayee, NonObjectEntryYieldsEmptyPayment) {
PackedPayment pp = normalize_payment(entry);
EXPECT_TRUE(pp.payee.empty());
EXPECT_EQ(pp.amount, 0u);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 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 <impl/dash/coinbase_builder.hpp> // compute_dash_payouts, build
#include <impl/dash/params.hpp> // dash::make_coin_params
#include <btclibs/util/strencodings.h> // ParseHex, HexStr

#include <map>
#include <span>
#include <stdexcept>
#include <vector>

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<dash::coin::PackedPayment> normalized_real_payments()
{
std::vector<dash::coin::PackedPayment> out;
for (const auto& e : real_testnet_masternode_entries())
out.push_back(normalize_payment(e));
return out;
}

bool payments_contain(const std::vector<dash::coinbase::MinerPayout>& 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<unsigned char>(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<unsigned char>(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<std::vector<unsigned char>, 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<std::vector<unsigned char>, 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<std::vector<unsigned char>, 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<std::vector<unsigned char>, 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<const unsigned char>(
layout.bytes.data(), layout.bytes.size())),
expected);
}
Loading
Loading