diff --git a/README.md b/README.md index bb796e1c6..72cc08a20 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,7 @@ complete examples with all options documented. | -- | `explorer_url` | -- | Explorer URL injected into dashboard nav (e.g. `http://localhost:9090`) | | -- | `explorer_depth_ltc` | 288 | LTC blocks to keep in explorer store | | -- | `explorer_depth_doge` | 1440 | DOGE blocks to keep in explorer store | -| `--coinbase-text` | `coinbase_text` | /c2pool/ | Custom coinbase scriptSig text | +| `--coinbase-text` | `coinbase_text` | see below | Custom coinbase scriptSig text | | `--message-blob-hex` | -- | -- | V36 authority message blob | | `--doge-testnet4alpha` | `doge_testnet4alpha` | false | Use DOGE testnet4alpha | @@ -465,7 +465,7 @@ Every block found by c2pool embeds structured data in the coinbase scriptSig: ``` [4] BIP34 block height (consensus) [44] AuxPoW merged mining commitment (when active) -[N] Operator text (--coinbase-text, default "/c2pool/") +[N] Operator text (--coinbase-text, see per-lane defaults below) [32] THE state root (sharechain state commitment) [M] THE metadata (pool analytics, fills remaining space) Total: 100 bytes (Bitcoin consensus limit) @@ -475,6 +475,29 @@ The THE state root commits the sharechain state at block-find time (PPLNS distribution, chain height, difficulty). Any node can verify a found block's payouts match the committed state root. +### Operator text defaults + +`--coinbase-text` is capped at 64 bytes (20 when merged mining is active, since +the AuxPoW commitment shares the same 100-byte budget). + +| Lane | Default operator text | +|------|-----------------------| +| LTC (`c2pool`) | `/c2pool/` | +| DASH (`c2pool-dash`) mainnet | `/P2Pool-DASH/c2pool/` | +| DASH (`c2pool-dash`) testnet / regtest | `/P2Pool-tDASH/c2pool/` | + +The DASH default embeds the canonical p2pool marker (`COINBASEEXT` from the +p2pool-dash oracle `networks/dash.py`) because block explorers attribute blocks +to a pool by coinbase text — [chainz.cryptoid.info](https://chainz.cryptoid.info/dash/extraction.dws?30.htm) +lists the pool as `P2Pool-DASH` and does not know the string `c2pool`. The +`c2pool/` suffix records which implementation mined the block. The coinbase text +is not consensus-bearing (peers never re-derive it), so overriding it cannot +orphan a share — but an override that drops `/P2Pool-DASH/` makes your blocks +unattributable on explorers. + +DASH sources this default from its coin SSOT (`src/impl/dash/config_pool.hpp`); +the same pattern is available for the other lanes but is not yet wired there. + --- ## Authority message blobs (V36) diff --git a/src/c2pool/main_dash.cpp b/src/c2pool/main_dash.cpp index f8e4db1af..e6cfa9ce3 100644 --- a/src/c2pool/main_dash.cpp +++ b/src/c2pool/main_dash.cpp @@ -46,6 +46,7 @@ #include #include +#include // c2pool::MAX_OPERATOR_TEXT_SOLO (--coinbase-text budget SSOT) #include // raise_nofile_limit (hotel interim fix #4) #include #include // NetService (dashd RPC endpoint) @@ -220,7 +221,7 @@ void print_banner(const char* argv0) << " [--give-author PCT] [-f|--fee PCT] [--node-owner-address ADDR]\n" << " [--redistribute pplns|fee|boost|donate]\n" << " [--coin-zmq-hashblock tcp://HOST:PORT]\n" - << " [--message-blob-hex HEX]\n" + << " [--message-blob-hex HEX] [--coinbase-text TEXT]\n" << " " << argv0 << " --mine-block [--coin-rpc H:P] [--coin-rpc-auth PATH]\n" << " [--testnet] [--payout-pubkey-hash HEX] [--max-nonce N]\n\n" << "Status: consensus layer live (X11 PoW, subsidy, oracle CoinParams).\n" @@ -244,6 +245,16 @@ void print_banner(const char* argv0) << " (default mainnet bf0c6bbd / testnet cee2caff; regtest fcc1b7dc).\n" << " --regtest-force-won-block (regtest E5 harness, fail-closed) drives\n" << " ONE real won block through the run-path dual-path dispatch.\n" + << " --coinbase-text TEXT sets the coinbase scriptSig text written\n" + << " after the BIP34 height push (README \"Coinbase structure\"; max\n" + << " 64 bytes, no merged mining on the DASH lane). Default\n" + << " \"/P2Pool-DASH/c2pool/\" (testnet \"/P2Pool-tDASH/c2pool/\") --\n" + << " the /P2Pool-DASH/ marker is what block explorers match on to\n" + << " attribute a block to this pool; the c2pool suffix says which\n" + << " implementation mined it. Non-consensus: the coinbase text is\n" + << " never re-derived by peers, so overriding it cannot orphan a\n" + << " share -- but an override that drops /P2Pool-DASH/ makes your\n" + << " blocks unattributable on explorers.\n" << " --coin-p2p-discover arms the DASH-isolated peer manager: seed\n" << " (dnsseed.dash.org + fixed) bootstrap, source-scored + group-diverse\n" << " (Sybil-capped) peer selection, anchors, and a self-healing dial\n" @@ -508,6 +519,16 @@ int run_node(bool testnet, const std::string& rpc_endpoint, // topology. Mirrors the dgb::Node bring-up (src/c2pool/main_dgb.cpp). dash::SharechainConfig::is_testnet = testnet; + // Effective coinbase scriptSig text, resolved from the coin SSOT (operator + // --coinbase-text override, else the network default). Logged at startup so + // the bytes explorers will see are visible without decoding a block. + std::cout << "[run] coinbase text: \"" + << dash::SharechainConfig::coinbase_text(testnet) << "\"" + << (dash::SharechainConfig::coinbase_text_override.empty() + ? " (default; --coinbase-text to customize)" + : " (--coinbase-text override)") + << "\n"; + // Bucket-1 ISOLATION PRIMITIVE: DASH keeps its own net subdir + PREFIX, // per-coin AND per-pool-instance, in v36 and v37 — never standardised. const std::string net_subdir = testnet ? "dash_testnet" : "dash"; @@ -1529,8 +1550,9 @@ int run_node(bool testnet, const std::string& rpc_endpoint, work.m_coinbase_value, work.m_packed_payments, payout_pkh, empty_weights, /*total_weight=*/0, params); auto layout = dash::coinbase::build( - work, tx_outs, /*pool_tag=*/"c2pool", params, - /*ref_hash=*/uint256::ZERO); + work, tx_outs, + /*coinbase_text=*/dash::SharechainConfig::coinbase_text(params.is_testnet), + params, /*ref_hash=*/uint256::ZERO); // 3) X11-mine to satisfy the template bits. dash::coin::MineResult mr = dash::coin::mine_block( work, layout.bytes, /*max_nonce=*/2000000ull); @@ -1852,7 +1874,8 @@ int run_node(bool testnet, const std::string& rpc_endpoint, guard->chain, mint_params, prev_share_hash, identity->payout_script, wd, static_cast(std::time(nullptr)), share_nonce, - identity->donation_u16, /*pool_tag=*/"c2pool", + identity->donation_u16, + /*coinbase_text=*/dash::SharechainConfig::coinbase_text(mint_params.is_testnet), local_hash_rate); if (!built) return std::nullopt; @@ -3459,8 +3482,10 @@ int run_mine_block(bool testnet, const std::string& rpc_endpoint, empty_weights, /*total_weight=*/0, params); // ref_hash is the PPLNS commitment; for a standalone producer block we use // zero (no sharechain commitment) -- consensus-irrelevant to dashd validity. - auto layout = dash::coinbase::build(work, tx_outs, /*pool_tag=*/"c2pool", - params, /*ref_hash=*/uint256::ZERO); + auto layout = dash::coinbase::build( + work, tx_outs, + /*coinbase_text=*/dash::SharechainConfig::coinbase_text(params.is_testnet), + params, /*ref_hash=*/uint256::ZERO); std::cout << "[mine] coinbase built: " << layout.bytes.size() << " bytes, " << tx_outs.size() << " outputs\n"; @@ -3533,6 +3558,7 @@ int main(int argc, char** argv) uint16_t stratum_port = 0; // 0 disables the Stratum accept-loop; --stratum sets it bool embedded_utxo = false; // --embedded-utxo: arm the E2b UTXO/fee lane (opt-in) double dev_donation = 0.1; // --give-author (donation_percentage; README default 0.1%) + std::string coinbase_text; // --coinbase-text (empty => network default from the SSOT) double node_owner_fee = 0.0; // -f / --fee (node_owner_fee; default 0) std::string node_owner_address; // --node-owner-address (fee destination) // Web dashboard (the EXISTING c2pool dashboard, same defaults as main_ltc.cpp: @@ -3603,6 +3629,8 @@ int main(int argc, char** argv) embedded_superblock = true; else if (std::strcmp(argv[i], "--embedded-utxo") == 0) embedded_utxo = true; + else if (std::strcmp(argv[i], "--coinbase-text") == 0 && i + 1 < argc) + coinbase_text = argv[++i]; else if ((std::strcmp(argv[i], "--give-author") == 0 || std::strcmp(argv[i], "--dev-donation") == 0) && i + 1 < argc) dev_donation = std::strtod(argv[++i], nullptr); @@ -3651,6 +3679,23 @@ int main(int argc, char** argv) // --selftest is the default; accepted explicitly for symmetry. } + // ── --coinbase-text: resolve ONCE, here, before any coinbase is built ──── + // DASH has no merged mining and writes no THE state-root/metadata tail, so + // the operator slot is bounded by MAX_OPERATOR_TEXT_SOLO (README "Coinbase + // structure"); the BIP34 height push (<=5 B) plus 64 B stays well inside the + // 100-byte scriptSig limit. Stored on the coin SSOT rather than threaded + // through every call so the stratum job path and the share-mint path cannot + // disagree -- a one-byte divergence between them would make the node + // self-reject its own shares. + if (!coinbase_text.empty()) { + if (coinbase_text.size() > c2pool::MAX_OPERATOR_TEXT_SOLO) { + std::cout << "[args] --coinbase-text too long: " << coinbase_text.size() + << " bytes (max " << c2pool::MAX_OPERATOR_TEXT_SOLO << ")\n"; + return 2; + } + dash::SharechainConfig::coinbase_text_override = coinbase_text; + } + print_banner(argv[0]); if (want_help) return 0; diff --git a/src/impl/dash/coinbase_builder.hpp b/src/impl/dash/coinbase_builder.hpp index f4dfe9f47..816e46c17 100644 --- a/src/impl/dash/coinbase_builder.hpp +++ b/src/impl/dash/coinbase_builder.hpp @@ -11,8 +11,8 @@ // [TxIn: // [prev_hash 32B zeros][prev_n 0xFFFFFFFF] // [VarStr scriptSig: -// [BIP34 height push][pool_tag] ← no extranonce here anymore -// ] +// [BIP34 height push][coinbase text] +// ] ← no extranonce here anymore // [sequence 0xFFFFFFFF] // ] // [vout VarInt] @@ -37,6 +37,7 @@ #include "coin/transaction.hpp" #include "coin/rpc_data.hpp" +#include "config_pool.hpp" // SSOT: SharechainConfig::COINBASEEXT_HEX / IMPL_TAG #include "share_check.hpp" // decode_payee_script, pubkey_hash_to_script2, DONATION_SCRIPT #include "payout_muldiv.hpp" // dash::payout::payout_share (MSVC-portable 128-bit muldiv) @@ -253,6 +254,84 @@ inline std::vector push_bip34_height(uint32_t height) return out; } +// Maximum coinbase scriptSig length. Oracle data.py Share.__init__ rejects a +// share whose share_data['coinbase'] is outside 2..100 bytes, and work.py:339 +// slices the assembled stratum scriptSig to [:100]. Both bounds are the same +// number; every scriptSig c2pool emits goes through build_coinbase_scriptsig() +// so neither can be breached from one path only. +static constexpr size_t MAX_SCRIPTSIG_LEN = 100; + +// ── build_coinbase_scriptsig — THE single coinbase-scriptSig SSOT ─────────── +// +// Emits: [BIP34 height push][coinbase text] truncated to 100 B +// +// mainnet, height 2511303, default text: +// 03 c75126 | 2f 50 32 50 6f 6f 6c 2d 44 41 53 48 2f 63 32 70 6f 6f 6c 2f +// = push3 "\xc7\x51\x26" + "/P2Pool-DASH/c2pool/" (24 bytes) +// +// `coinbase_text` is the operator-facing text slot (--coinbase-text / +// pool.yaml coinbase_text, README "Coinbase structure"). Pass an empty string +// to take the network default from the SSOT — SharechainConfig::coinbase_text() +// resolves the override-or-default, so the stratum job path and the share-mint +// path cannot end up disagreeing. +// +// WHY THIS DEFAULT +// * "/P2Pool-DASH/" is the oracle's COINBASEEXT payload (networks/dash.py:11; +// testnet "/P2Pool-tDASH/"). Block explorers attribute blocks to a pool by +// coinbase text — chainz.cryptoid.info registers this pool as "P2Pool-DASH" +// and has no knowledge of the string "c2pool", so before this every block +// the pool won through c2pool was credited to nobody. +// * "c2pool/" follows so a human reading the coinbase can still tell WHICH +// p2pool implementation produced the block. +// +// FRAMING — the two things that must not move +// 1. BIP34. The height push stays FIRST and can never be displaced or +// truncated: dashd's ContextualCheckBlock compares the scriptSig PREFIX to +// CScript() << nHeight, so anything appended after it is invisible to that +// check. Worst case the push is 5 bytes (data-push of a 4-byte +// CScriptNum), leaving 95 for the text; the default needs 20 (mainnet) / +// 21 (testnet) and --coinbase-text is capped at 64 by the caller, so the +// 100-byte truncation cannot reach back into the height push. +// 2. Extranonce offsets. DASH's stratum extranonce2 is the 8-byte nonce64 +// inside the OP_RETURN output, NOT inside the scriptSig. coinb1/coinb2 +// split at nonce64_offset, which build() derives from the tx TAIL +// (total - payload - 4 - 8). A longer scriptSig lengthens coinb1 and +// leaves the advertised extranonce2_size (8) and coinb2 byte-identical. +// +// CONSENSUS: the coinbase text is a customizable parameter and is not part of +// what peers re-derive. Oracle data.py Share.check() calls +// generate_transaction(tracker, SELF.share_info['share_data'], ...) — it feeds +// the RECEIVED share's own coinbase field back in; COINBASEEXT appears nowhere +// in data.py. c2pool's mirror, share_check.hpp::generate_share_transaction, +// likewise does `tx << share.m_coinbase`. The hash_link is length-agnostic +// (prefix_to_hash_link/check_hash_link derive the 64-byte-block split from the +// actual prefix length, and dash's hash_link_type carries extra_data as a +// VarStr precisely so a variable-length prefix works). Empirically: c2pool has +// been writing "c2pool" — matching no network constant — and canonical +// p2pool-dash peers accept its shares with zero bans across thousands of shares +// and two dashd-confirmed mainnet blocks (2511241, 2511303). +// +// The ONE hazard this function exists to close: dash builds the scriptSig in +// TWO places — coinbase::build() (the stratum job / real block coinbase) and +// mint::build_producer_job() (share_data['coinbase']). If those two ever +// disagree by a single byte, the minted share's gentx no longer matches the +// block coinbase and the node self-rejects. They now both call this. +inline std::vector build_coinbase_scriptsig( + uint32_t height, const std::string& coinbase_text, bool testnet) +{ + std::vector script = push_bip34_height(height); + + const std::string text = coinbase_text.empty() + ? dash::SharechainConfig::coinbase_text(testnet) + : coinbase_text; + + for (unsigned char c : text) { + if (script.size() >= MAX_SCRIPTSIG_LEN) break; // oracle work.py [:100] + script.push_back(c); + } + return script; +} + // Convert compact nbits → share difficulty (Bitcoin bdiff formula). inline double bits_to_difficulty(uint32_t nbits) { @@ -275,16 +354,16 @@ inline double bits_to_difficulty(uint32_t nbits) // DONATION_SCRIPT so gentx_before_refhash lines up. // Zero-value entries are emitted as-is (callers that // want to strip them should do so before calling). -// pool_tag — short tag in coinbase scriptSig (no extranonce) -// params — coin params (unused here; retained for symmetry) +// coinbase_text — operator coinbase scriptSig text (--coinbase-text); +// empty -> the network default from the SSOT (no extranonce) +// params — coin params; params.is_testnet selects the network default // ref_hash — 32B PPLNS commitment embedded in OP_RETURN inline CoinbaseLayout build(const dash::coin::DashWorkData& work, const std::vector& tx_outs_ordered, - const std::string& pool_tag, + const std::string& coinbase_text, const core::CoinParams& params, const uint256& ref_hash) { - (void)params; using namespace dash::coin; MutableTransaction tx; @@ -295,10 +374,11 @@ inline CoinbaseLayout build(const dash::coin::DashWorkData& work, in.prevout.index = 0xFFFFFFFFu; in.sequence = 0xFFFFFFFFu; { - std::vector script; - auto h_push = push_bip34_height(work.m_height); - script.insert(script.end(), h_push.begin(), h_push.end()); - script.insert(script.end(), pool_tag.begin(), pool_tag.end()); + // SSOT: same helper mint::build_producer_job uses for + // share_data['coinbase'] — the two must stay byte-identical or a + // minted share's gentx stops matching the block coinbase. + auto script = build_coinbase_scriptsig( + work.m_height, coinbase_text, params.is_testnet); in.scriptSig = OPScript(script.data(), script.data() + script.size()); } tx.vin.push_back(std::move(in)); diff --git a/src/impl/dash/config_pool.hpp b/src/impl/dash/config_pool.hpp index 0b79d8dbc..ac5f8af29 100644 --- a/src/impl/dash/config_pool.hpp +++ b/src/impl/dash/config_pool.hpp @@ -88,6 +88,128 @@ struct SharechainConfig static const std::string& identifier_hex() { return is_testnet ? TESTNET_IDENTIFIER_HEX : IDENTIFIER_HEX; } static const std::string& prefix_hex() { return is_testnet ? TESTNET_PREFIX_HEX : PREFIX_HEX; } + // ---- COINBASEEXT: the canonical p2pool coinbase marker ------------------ + // SOURCE OF TRUTH: oracle networks/dash.py:11 / dash_testnet.py:11 — + // dash.py COINBASEEXT = '0D2F5032506F6F6C2D444153482F'.decode('hex') + // dash_testnet.py COINBASEEXT = '0E2F5032506F6F6C2D74444153482F'.decode('hex') + // Transcribed VERBATIM, including the leading one-byte push opcode (0x0D = + // push 13, the length of "/P2Pool-DASH/"; 0x0E = push 14 for + // "/P2Pool-tDASH/"), so this header pins the oracle constant exactly as the + // oracle writes it. What c2pool EMITS is the text payload with the push + // opcode stripped — see coinbaseext_text() for why. + // + // WHY IT EXISTS: block explorers attribute blocks to a pool BY COINBASE + // TEXT. chainz.cryptoid.info/dash/extraction.dws?30.htm registers the pool + // as "P2Pool-DASH" and has no knowledge of the string "c2pool", so blocks + // c2pool won for the p2pool-dash sharechain were credited to nobody. + // + // CONSENSUS STATUS: NOT consensus-bearing — the coinbase text is a + // customizable parameter (that is what --coinbase-text is). COINBASEEXT + // appears NOWHERE in the oracle's data.py (the consensus module); it lives + // only in networks/*.py and the stratum assembly at work.py:339. The + // scriptSig travels on the wire as share_info.share_data.coinbase (VarStr, + // 2..100 B) and Share.check() re-derives the gentx from the RECEIVED + // share's own coinbase field, never from a network constant. Framing notes + // (BIP34 prefix, extranonce offsets) on build_coinbase_scriptsig(). + // + // REGTEST: the oracle's dash_regtest.py:11 constant + // '0F2F5032506F6F6C2D724441534828' is MALFORMED — it declares push-15 but + // carries only 14 bytes, and its final byte is 0x28 '(' where a '/' (0x2F) + // was clearly intended ("/P2Pool-rDASH("). c2pool has no separate regtest + // sharechain profile (main_dash.cpp maps --regtest onto testnet=true), so a + // c2pool regtest node emits the tDASH marker and the oracle typo is not + // reproduced. Recorded here so the divergence is deliberate, not drift. + static inline const std::string COINBASEEXT_HEX = "0D2F5032506F6F6C2D444153482F"; + static inline const std::string TESTNET_COINBASEEXT_HEX = "0E2F5032506F6F6C2D74444153482F"; + + // Implementation tag appended after the marker so a human reading the + // coinbase can tell WHICH p2pool implementation produced the block. Shared + // by mainnet and testnet. + static inline const std::string IMPL_TAG = "c2pool/"; + + // Explicit-network forms — PREFERRED. Callers on the coinbase path already + // hold core::CoinParams::is_testnet, so they never have to trust the mutable + // process-global below (and tests can exercise both networks in one binary). + static const std::string& coinbaseext_hex(bool testnet) + { + return testnet ? TESTNET_COINBASEEXT_HEX : COINBASEEXT_HEX; + } + static const std::string& coinbaseext_hex() { return coinbaseext_hex(is_testnet); } + + // COINBASEEXT decoded to raw bytes, push opcode INCLUDED — the oracle's + // literal constant. Local decoder: this header is the SSOT the conformance + // tests pin and deliberately carries no dependency beyond + // //uint256. + static std::string coinbaseext_bytes(bool testnet) + { + const std::string& h = coinbaseext_hex(testnet); + 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::string out; + out.reserve(h.size() / 2); + for (size_t i = 0; i + 1 < h.size(); i += 2) { + const int hi = nib(h[i]), lo = nib(h[i + 1]); + if (hi < 0 || lo < 0) return {}; // malformed SSOT -> emit nothing + out.push_back(static_cast((hi << 4) | lo)); + } + return out; + } + static std::string coinbaseext_bytes() { return coinbaseext_bytes(is_testnet); } + + // The marker TEXT — COINBASEEXT with its leading push opcode stripped: + // mainnet "/P2Pool-DASH/" testnet "/P2Pool-tDASH/" + // + // This, not the raw constant, is what c2pool writes. The push opcode is + // dropped deliberately: c2pool exposes the coinbase scriptSig payload as an + // operator-settable TEXT parameter (--coinbase-text, README "Coinbase + // structure"), and a bare control byte inside a text field would be lost the + // moment an operator overrode it — the default would then behave unlike + // every other value the field can take. Attribution is unaffected: explorers + // key on the coinbase TEXT (cryptoid lists this pool as "P2Pool-DASH"), and + // the ASCII substring "/P2Pool-DASH/" is byte-identical to what a canonical + // p2pool-dash node renders. The push byte is a script-encoding artefact of + // p2pool's assembly, not part of the name. + // + // Fail-closed: if the SSOT hex is ever edited into an inconsistent state + // (leading byte != payload length, as in the oracle's own regtest constant), + // this returns the decoded bytes unchanged rather than silently trimming a + // real character. + static std::string coinbaseext_text(bool testnet) + { + std::string b = coinbaseext_bytes(testnet); + if (b.size() >= 2 && + static_cast(b[0]) == b.size() - 1) + return b.substr(1); + return b; + } + + // Default coinbase scriptSig text for this network: + // mainnet "/P2Pool-DASH/c2pool/" testnet "/P2Pool-tDASH/c2pool/" + // The p2pool marker makes explorers attribute the block to the pool; the + // c2pool suffix says which implementation mined it. + static std::string default_coinbase_text(bool testnet) + { + return coinbaseext_text(testnet) + IMPL_TAG; + } + + // ---- Operator override (--coinbase-text / pool.yaml coinbase_text) ------- + // Pool-level runtime setting, resolved ONCE at startup in main_dash.cpp + // before any coinbase is built. Empty means "use the network default". + // Lives here rather than being threaded through every build call so the + // stratum job path and the share-mint path cannot end up disagreeing. + static inline std::string coinbase_text_override; + + static std::string coinbase_text(bool testnet) + { + return coinbase_text_override.empty() ? default_coinbase_text(testnet) + : coinbase_text_override; + } + static std::string coinbase_text() { return coinbase_text(is_testnet); } + // ---- Dust threshold (payout-dust semantic) ----------------------------- // DUST_THRESHOLD: minimum per-recipient payout to justify a coinbase output. // SOURCE: p2pool-dash oracle DUST_THRESHOLD = 0.001e8 = 100000 satoshi diff --git a/src/impl/dash/mint_runloop.hpp b/src/impl/dash/mint_runloop.hpp index ae5d6b027..4e016f2c7 100644 --- a/src/impl/dash/mint_runloop.hpp +++ b/src/impl/dash/mint_runloop.hpp @@ -33,7 +33,7 @@ #include "share_producer.hpp" #include "share_producer_bind.hpp" // FrozenMintJob, build_mint_share -#include "coinbase_builder.hpp" // push_bip34_height (BIP34 scriptSig SSOT) +#include "coinbase_builder.hpp" // build_coinbase_scriptsig (BIP34 + coinbase-text SSOT) #include "coin/rpc_data.hpp" // dash::coin::DashWorkData #include "stratum/work_source.hpp" // DASHWorkSource::{MintShareInputs, ProducerJob, PplnsWeights} #include "stratum/work_target.hpp" // dash::stratum::modulate_desired_share_target (Cap 1) @@ -125,7 +125,7 @@ inline std::optional build_producer_job( uint32_t desired_timestamp, uint32_t share_nonce, uint16_t donation, - const std::string& pool_tag, + const std::string& coinbase_text, double local_hash_rate = 0.0) { // Miner identity: DASH sharechain payouts are P2PKH-keyed (share_data @@ -137,16 +137,17 @@ inline std::optional build_producer_job( if (wd.m_bits == 0 || wd.m_height == 0) return std::nullopt; // no real template -> nothing to commit to - // share_data['coinbase'] — BIP34 height push + pool tag, capped at the - // verifier's 100-byte scriptSig bound (oracle work.py packs height+flags - // and slices [:100]). push_bip34_height is the same SSOT coinbase::build - // uses, so the share-gentx scriptSig matches dashd's bad-cb-height check. - std::vector script_sig = dash::coinbase::push_bip34_height(wd.m_height); - for (char c : pool_tag) { - if (script_sig.size() >= 100) break; - script_sig.push_back(static_cast(c)); - } - if (script_sig.size() < 2 || script_sig.size() > 100) + // share_data['coinbase'] — BIP34 height push + the pool's coinbase text + // (default "/P2Pool-DASH/c2pool/"), capped at the verifier's 100-byte + // scriptSig bound (oracle work.py packs height+flags+COINBASEEXT and slices + // [:100]). build_coinbase_scriptsig is the SHARED SSOT coinbase::build uses, so + // this share-gentx scriptSig is byte-identical to the block coinbase served + // over stratum (a divergence here would make the node self-reject its own + // shares) and still carries dashd's bad-cb-height BIP34 prefix first. + std::vector script_sig = + dash::coinbase::build_coinbase_scriptsig(wd.m_height, coinbase_text, params.is_testnet); + if (script_sig.size() < 2 || + script_sig.size() > dash::coinbase::MAX_SCRIPTSIG_LEN) return std::nullopt; // verifier bound (share_check) — fail-closed dash::producer::ProducerJobInputs pin; diff --git a/src/impl/dash/stratum/work_source.cpp b/src/impl/dash/stratum/work_source.cpp index 21a124be1..6d16da95d 100644 --- a/src/impl/dash/stratum/work_source.cpp +++ b/src/impl/dash/stratum/work_source.cpp @@ -695,7 +695,9 @@ core::stratum::CoinbaseResult DASHWorkSource::build_connection_coinbase( weights, total_weight, params); dash::coinbase::CoinbaseLayout layout = dash::coinbase::build( - *wd, tx_outs, /*pool_tag=*/"c2pool", params, ref_hash); + *wd, tx_outs, + /*coinbase_text=*/dash::SharechainConfig::coinbase_text(params.is_testnet), + params, ref_hash); dash::coinbase::CoinbSplit split = dash::coinbase::split_coinb(layout); core::stratum::CoinbaseResult out; diff --git a/test/test_dash_coinbase_parity.cpp b/test/test_dash_coinbase_parity.cpp index 77ed347ca..19a03fea4 100644 --- a/test/test_dash_coinbase_parity.cpp +++ b/test/test_dash_coinbase_parity.cpp @@ -29,6 +29,7 @@ #include +#include #include #include #include @@ -243,4 +244,296 @@ TEST(DashCoinbaseParity, V36DonationFloorOneSat) { uint64_t sum = 0; for (auto& o : outs) sum += o.amount; EXPECT_EQ(sum, subsidy); // invariant preserved -} \ No newline at end of file +} + + +// ───────────────────────────────────────────────────────────────────────────── +// (11)-(19) Coinbase text — the canonical p2pool marker + --coinbase-text. +// +// Block explorers attribute blocks to a pool BY COINBASE TEXT. +// chainz.cryptoid.info/dash/extraction.dws?30.htm registers the pool as +// "P2Pool-DASH"; it has no knowledge of the string "c2pool". Before this, +// c2pool's DASH coinbase read `03c751266332706f6f6c` = BIP34 height + "c2pool", +// so blocks the pool won through c2pool (2511241, 2511303) were attributed to +// nobody. The default coinbase text is now the oracle COINBASEEXT payload +// ("/P2Pool-DASH/", networks/dash.py:11) plus a "c2pool/" implementation tag, +// sourced from the coin SSOT (config_pool.hpp) and overridable per-pool with +// --coinbase-text. +// +// NOT consensus-bearing: the coinbase text is a customizable parameter. +// COINBASEEXT appears nowhere in the oracle's data.py; the scriptSig travels as +// share_info.share_data.coinbase and Share.check() re-derives the gentx from the +// RECEIVED share's own coinbase field. What these tests DO guard is FRAMING — +// the BIP34 height push must stay first, the 100-byte bound must hold, and the +// stratum extranonce2 slot must not move. +// ───────────────────────────────────────────────────────────────────────────── + +namespace { + +// Oracle marker payloads, transcribed independently of config_pool.hpp so a +// silent edit to the SSOT cannot silently move these KATs with it. +// networks/dash.py:11 '0D2F5032506F6F6C2D444153482F' = 0x0D "/P2Pool-DASH/" +// networks/dash_testnet.py:11 '0E2F5032506F6F6C2D74444153482F' = 0x0E "/P2Pool-tDASH/" +const std::string kOracleMarkerMain = "/P2Pool-DASH/"; +const std::string kOracleMarkerTest = "/P2Pool-tDASH/"; + +std::string ascii(const std::vector& v) { + return std::string(v.begin(), v.end()); +} + +// RAII for the pool-level --coinbase-text override: it is a process global, so +// one test must not leak into the next. +struct ScopedCoinbaseText { + std::string saved; + explicit ScopedCoinbaseText(const std::string& v) + : saved(dash::SharechainConfig::coinbase_text_override) { + dash::SharechainConfig::coinbase_text_override = v; + } + ~ScopedCoinbaseText() { + dash::SharechainConfig::coinbase_text_override = saved; + } +}; + +} // namespace + +// (11) MAINNET KAT — exact scriptSig bytes at the real height of the block the +// pool won on 2026-07-24 (2511303 = 0x2651c7 -> BIP34 push `03 c7 51 26`). +TEST(DashCoinbaseMarker, MainnetScriptSigKAT) { + ScopedCoinbaseText no_override(""); + auto s = dash::coinbase::build_coinbase_scriptsig( + /*height=*/2511303, /*coinbase_text=*/"", /*testnet=*/false); + + EXPECT_EQ(hex(s), + "03c75126" // BIP34 push3, height 2511303 LE + "2f5032506f6f6c2d444153482f6332706f6f6c2f"); // "/P2Pool-DASH/c2pool/" + EXPECT_EQ(s.size(), 24u); + EXPECT_EQ(ascii(s).substr(4), "/P2Pool-DASH/c2pool/"); + // The marker an explorer matches on is present verbatim. + EXPECT_NE(ascii(s).find(kOracleMarkerMain), std::string::npos); +} + +// (12) TESTNET KAT — the tDASH variant, never the mainnet string. (c2pool has +// no separate regtest sharechain profile: main_dash.cpp maps --regtest +// onto testnet=true, so regtest emits these same bytes.) +TEST(DashCoinbaseMarker, TestnetScriptSigKAT) { + ScopedCoinbaseText no_override(""); + auto s = dash::coinbase::build_coinbase_scriptsig( + /*height=*/950000, /*coinbase_text=*/"", /*testnet=*/true); + + EXPECT_EQ(hex(s), + "03f07e0e" // BIP34 push3, height 950000 LE + "2f5032506f6f6c2d74444153482f6332706f6f6c2f"); // "/P2Pool-tDASH/c2pool/" + EXPECT_EQ(s.size(), 25u); + EXPECT_EQ(ascii(s).substr(4), "/P2Pool-tDASH/c2pool/"); + EXPECT_NE(ascii(s).find(kOracleMarkerTest), std::string::npos); + // Testnet must NOT carry the mainnet marker. + EXPECT_EQ(ascii(s).find(kOracleMarkerMain), std::string::npos); +} + +// (13) NEGATIVE CONTROL — the bytes c2pool used to emit. This is the exact +// scriptSig observed on live mainnet block 2511303 before the change; it +// must satisfy none of the assertions above, proving they discriminate. +TEST(DashCoinbaseMarker, LegacyBareTagIsNotAttributable) { + ScopedCoinbaseText no_override(""); + + // Reconstruct the pre-change form: BIP34 height + raw "c2pool", no marker. + std::vector legacy = dash::coinbase::push_bip34_height(2511303); + for (char c : std::string("c2pool")) + legacy.push_back(static_cast(c)); + + EXPECT_EQ(hex(legacy), "03c751266332706f6f6c"); // as seen on-chain + EXPECT_EQ(ascii(legacy).find("P2Pool"), std::string::npos); + EXPECT_EQ(ascii(legacy).find(kOracleMarkerMain), std::string::npos); + + auto now = dash::coinbase::build_coinbase_scriptsig(2511303, "", false); + EXPECT_NE(hex(now), hex(legacy)); + // The BIP34 height push is byte-identical across old and new — the text is + // appended, never displacing dashd's ContextualCheckBlock prefix. + EXPECT_EQ(hex(now).substr(0, 8), hex(legacy).substr(0, 8)); +} + +// (14) SSOT wiring — config_pool.hpp carries the oracle hex verbatim (push +// opcode included) and coinbaseext_text() strips exactly that opcode. +TEST(DashCoinbaseMarker, ConfigPoolCarriesOracleConstant) { + EXPECT_EQ(dash::SharechainConfig::COINBASEEXT_HEX, + "0D2F5032506F6F6C2D444153482F"); + EXPECT_EQ(dash::SharechainConfig::TESTNET_COINBASEEXT_HEX, + "0E2F5032506F6F6C2D74444153482F"); + + // Raw oracle bytes: leading push opcode == payload length. + const std::string m = dash::SharechainConfig::coinbaseext_bytes(false); + ASSERT_EQ(m.size(), 14u); + EXPECT_EQ(static_cast(m[0]), 0x0D); + EXPECT_EQ(m.size() - 1, static_cast(static_cast(m[0]))); + const std::string t = dash::SharechainConfig::coinbaseext_bytes(true); + ASSERT_EQ(t.size(), 15u); + EXPECT_EQ(static_cast(t[0]), 0x0E); + EXPECT_EQ(t.size() - 1, static_cast(static_cast(t[0]))); + + // Text form == the ASCII an explorer reads. + EXPECT_EQ(dash::SharechainConfig::coinbaseext_text(false), kOracleMarkerMain); + EXPECT_EQ(dash::SharechainConfig::coinbaseext_text(true), kOracleMarkerTest); + + EXPECT_EQ(dash::SharechainConfig::default_coinbase_text(false), + "/P2Pool-DASH/c2pool/"); + EXPECT_EQ(dash::SharechainConfig::default_coinbase_text(true), + "/P2Pool-tDASH/c2pool/"); +} + +// (15) --coinbase-text override: the operator value replaces the default on +// BOTH the explicit-argument path and the SSOT-resolved path, and the +// default returns once the override is cleared. +TEST(DashCoinbaseMarker, OperatorOverrideReplacesDefault) { + { + ScopedCoinbaseText ov("/my-pool/"); + EXPECT_EQ(dash::SharechainConfig::coinbase_text(false), "/my-pool/"); + auto s = dash::coinbase::build_coinbase_scriptsig(2511303, "", false); + EXPECT_EQ(hex(s), "03c75126" "2f6d792d706f6f6c2f"); // "/my-pool/" + EXPECT_EQ(ascii(s).find(kOracleMarkerMain), std::string::npos); + + // An explicit non-empty argument still wins over the pool setting — + // that is what the E5/--mine-block call sites pass. + auto e = dash::coinbase::build_coinbase_scriptsig(2511303, "/other/", false); + EXPECT_EQ(ascii(e).substr(4), "/other/"); + } + // Override scope ended -> default restored. + ScopedCoinbaseText no_override(""); + EXPECT_EQ(dash::SharechainConfig::coinbase_text(false), "/P2Pool-DASH/c2pool/"); +} + +// (16) 100-byte bound + BIP34 integrity under an absurd text. The oracle rejects +// share_data['coinbase'] outside 2..100 bytes (data.py:315) and work.py +// slices [:100]; the height push must survive both. +TEST(DashCoinbaseMarker, TruncationRespectsBoundAndKeepsHeightPush) { + ScopedCoinbaseText no_override(""); + const std::string absurd(400, 'X'); + auto s = dash::coinbase::build_coinbase_scriptsig(2511303, absurd, false); + + EXPECT_EQ(s.size(), dash::coinbase::MAX_SCRIPTSIG_LEN); // exactly 100, never more + EXPECT_GE(s.size(), 2u); + // BIP34 height push intact and still FIRST. + auto h = dash::coinbase::push_bip34_height(2511303); + ASSERT_GE(s.size(), h.size()); + EXPECT_TRUE(std::equal(h.begin(), h.end(), s.begin())); + + // Worst-case BIP34 push is 5 bytes (4-byte CScriptNum + length); even then + // the default text is nowhere near the cap, and --coinbase-text is bounded + // at c2pool::MAX_OPERATOR_TEXT_SOLO (64) by main_dash.cpp. + auto tall = dash::coinbase::push_bip34_height(0x7FFFFFFFu); + EXPECT_EQ(tall.size(), 5u); + auto s2 = dash::coinbase::build_coinbase_scriptsig(0x7FFFFFFFu, "", false); + EXPECT_EQ(s2.size(), 5u + 20u); + EXPECT_LT(5u + 64u, dash::coinbase::MAX_SCRIPTSIG_LEN); + EXPECT_NE(ascii(s2).find(kOracleMarkerMain), std::string::npos); +} + +// (17) END-TO-END: the text actually lands in the coinbase TX that +// coinbase::build() serializes (this is the tx a winning miner submits). +// Fixed tx prefix: [version 4][vin count 1][prev_hash 32][prev_n 4] = 41 +// bytes, so the scriptSig VarStr length byte sits at offset 41. +TEST(DashCoinbaseMarker, BuildEmitsMarkerInSerializedCoinbase) { + ScopedCoinbaseText no_override(""); + auto params = dash::make_coin_params(/*testnet=*/false); + ASSERT_FALSE(params.is_testnet); + + dash::coin::DashWorkData work; + work.m_height = 2511303; + work.m_coinbase_value = 5000000000ull; + work.m_bits = 0x1b0404cb; + + std::vector finder_h(20, 0x07); + auto tx_outs = dash::coinbase::compute_dash_payouts( + work.m_coinbase_value, /*payments=*/{}, uint160(finder_h), + /*weights=*/{}, /*total_weight=*/0, params); + + auto layout = dash::coinbase::build( + work, tx_outs, + dash::SharechainConfig::coinbase_text(params.is_testnet), + params, uint256::ZERO); + + const auto expected = dash::coinbase::build_coinbase_scriptsig( + work.m_height, /*coinbase_text=*/"", params.is_testnet); + + ASSERT_GT(layout.bytes.size(), 41u + 1u + expected.size()); + EXPECT_EQ(layout.bytes[41], static_cast(expected.size())); + std::vector on_wire(layout.bytes.begin() + 42, + layout.bytes.begin() + 42 + expected.size()); + EXPECT_EQ(hex(on_wire), hex(expected)); + EXPECT_EQ(hex(on_wire), + "03c751262f5032506f6f6c2d444153482f6332706f6f6c2f"); + + // FRAMING: the split point is derived from the tx TAIL, so a longer + // scriptSig cannot move the 8-byte extranonce2 slot — coinb2 stays + // [locktime||payload] and the advertised extranonce2_size is unchanged. + auto split = dash::coinbase::split_coinb(layout); + EXPECT_EQ(dash::coinbase::EXTRANONCE2_SIZE, 8u); + EXPECT_EQ(split.coinb1_hex.size(), layout.nonce64_offset * 2); + EXPECT_EQ(split.coinb2_hex.size(), + (layout.bytes.size() - layout.nonce64_offset - 8) * 2); + EXPECT_EQ(split.coinb2_hex, "00000000"); // locktime only, no payload + EXPECT_EQ(layout.nonce64_offset, layout.ref_hash_offset + 32); +} + +// (18) Testnet params select the testnet text end-to-end (build() reads +// params.is_testnet, not the mutable process-global). +TEST(DashCoinbaseMarker, BuildHonoursTestnetParams) { + ScopedCoinbaseText no_override(""); + auto params = dash::make_coin_params(/*testnet=*/true); + ASSERT_TRUE(params.is_testnet); + + dash::coin::DashWorkData work; + work.m_height = 950000; + work.m_coinbase_value = 5000000000ull; + + std::vector finder_h(20, 0x07); + auto tx_outs = dash::coinbase::compute_dash_payouts( + work.m_coinbase_value, /*payments=*/{}, uint160(finder_h), + /*weights=*/{}, /*total_weight=*/0, params); + auto layout = dash::coinbase::build( + work, tx_outs, + dash::SharechainConfig::coinbase_text(params.is_testnet), + params, uint256::ZERO); + + const size_t len = layout.bytes[41]; + std::vector on_wire(layout.bytes.begin() + 42, + layout.bytes.begin() + 42 + len); + EXPECT_EQ(hex(on_wire), + "03f07e0e2f5032506f6f6c2d74444153482f6332706f6f6c2f"); + EXPECT_EQ(ascii(on_wire).find(kOracleMarkerMain), std::string::npos); +} + +// (19) The SHARE-MINT path and the BLOCK-COINBASE path derive their scriptSig +// from the same helper. If they ever disagree by one byte the minted +// share's gentx stops matching the block coinbase and the node +// self-rejects, so pin the equality explicitly: what mint_runloop.hpp +// writes into share_data['coinbase'] is exactly what build() serializes. +TEST(DashCoinbaseMarker, MintAndBlockCoinbaseScriptSigsAgree) { + ScopedCoinbaseText no_override(""); + for (bool testnet : {false, true}) { + auto params = dash::make_coin_params(testnet); + dash::coin::DashWorkData work; + work.m_height = testnet ? 950000u : 2511303u; + work.m_coinbase_value = 5000000000ull; + + const std::string resolved = + dash::SharechainConfig::coinbase_text(params.is_testnet); + + // mint_runloop.hpp::build_producer_job body, verbatim. + auto mint_sig = dash::coinbase::build_coinbase_scriptsig( + work.m_height, resolved, params.is_testnet); + ASSERT_GE(mint_sig.size(), 2u); + ASSERT_LE(mint_sig.size(), dash::coinbase::MAX_SCRIPTSIG_LEN); + + std::vector finder_h(20, 0x07); + auto tx_outs = dash::coinbase::compute_dash_payouts( + work.m_coinbase_value, /*payments=*/{}, uint160(finder_h), + /*weights=*/{}, /*total_weight=*/0, params); + auto layout = dash::coinbase::build( + work, tx_outs, resolved, params, uint256::ZERO); + + const size_t len = layout.bytes[41]; + std::vector block_sig( + layout.bytes.begin() + 42, layout.bytes.begin() + 42 + len); + EXPECT_EQ(hex(block_sig), hex(mint_sig)) << "testnet=" << testnet; + } +} diff --git a/test/test_dash_stratum_work_source.cpp b/test/test_dash_stratum_work_source.cpp index 96fc5f8da..4097621cf 100644 --- a/test/test_dash_stratum_work_source.cpp +++ b/test/test_dash_stratum_work_source.cpp @@ -385,8 +385,13 @@ TEST(DashStratumWorkSource, ConnectionCoinbaseIsByteIdenticalToVerifierSSOT) auto tx_outs = dash::coinbase::compute_dash_payouts( kCoinbaseValue, w.m_packed_payments, fixture_miner_pkh(), weights, /*total_weight=*/1, params); - auto layout = dash::coinbase::build(w, tx_outs, "c2pool", params, - /*ref_hash=*/uint256::ZERO); + // Coinbase text comes from the coin SSOT (config_pool.hpp), the same source + // build_connection_coinbase resolves — pinning the literal here would make + // this byte-identity test fail the moment the pool's coinbase text changes, + // which is exactly the knob --coinbase-text exposes. + auto layout = dash::coinbase::build( + w, tx_outs, dash::SharechainConfig::coinbase_text(params.is_testnet), + params, /*ref_hash=*/uint256::ZERO); EXPECT_EQ(reassembled, layout.bytes); // Value splits: worker payout = subsidy - masternode share; the single