diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5cf12acbf..06dc4ab8c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,7 +59,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain test_dash_block_replay \ + test_dash_header_chain test_dash_block_replay test_dash_conformance \ test_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -186,7 +186,7 @@ jobs: test_threading test_weights \ test_header_chain test_mempool test_template_builder \ test_doge_chain test_compact_blocks test_dash_x11_kat \ - test_dash_header_chain test_dash_block_replay \ + test_dash_header_chain test_dash_block_replay test_dash_conformance \ test_hash_link test_decay_pplns \ test_pplns_consensus \ test_v36_script_sorting test_v36_cross_impl_refhash \ diff --git a/src/impl/dash/version_negotiation.hpp b/src/impl/dash/version_negotiation.hpp new file mode 100644 index 000000000..f7807f69f --- /dev/null +++ b/src/impl/dash/version_negotiation.hpp @@ -0,0 +1,130 @@ +#pragma once + +// Dash share-version transition negotiation (older-than-v35 -> v36). +// +// Conforms to frstrtr/p2pool-dash (OLDER oracle) — Option A, not a c2pool +// reinterpretation. Two distinct tallies, deliberately kept apart (the F10 +// version-gate trap): +// +// * get_desired_version_counts() — a PLAIN tally (one vote per share) over a +// chain window. Reference: p2pool-dash data.py get_desired_version_counts +// as consumed by Share.check()'s confirmed-state guard and by the +// AutoRatchet desired-version selection. It is NOT weighted in place. +// +// * get_desired_version_weights() — the SEPARATE WEIGHTED variant +// (weight = target_to_average_attempts(target), i.e. expected hashes) the +// v36 activation gate consumes. Reference: p2pool-merged-v36 work.py +// v36_active = weight[36] / Sum(weight) >= 0.95. +// +// The confirmed-state guard (Share.check): a SUCCESSOR-version share may follow +// its predecessor only if the new version already holds >= 60% of the PLAIN +// votes in the [9/10 .. 10/10] tail of the CHAIN_LENGTH window; a switch with +// fewer than CHAIN_LENGTH ancestors is rejected ("without enough history"). + +#include "share_chain.hpp" // dash::ShareChain, dash::DashShare + +#include // chain::target_to_average_attempts, bits_to_target +#include // uint256, uint288 + +#include +#include +#include +#include + +namespace dash::version_negotiation +{ + +// PLAIN desired-version tally over the `dist` shares ending at `start_hash` +// (inclusive, walking back via prev_hash). One vote per share. Mirrors the +// older p2pool-dash get_desired_version_counts as used by the SUCCESSOR +// tail-guard / AutoRatchet — do NOT add work weighting here (F10 trap). +inline std::map +get_desired_version_counts(ShareChain& chain, const uint256& start_hash, uint64_t dist) +{ + std::map res; + if (!chain.contains(start_hash)) return res; + const uint64_t n = std::min(dist, + static_cast(chain.get_height(start_hash))); + for (auto&& [h, data] : chain.get_chain(start_hash, n)) { + (void)h; + data.share.invoke([&](auto* obj) { + using S = std::remove_pointer_t; + if constexpr (std::is_same_v) + res[obj->m_desired_version] += 1; + }); + } + return res; +} + +// WEIGHTED desired-version tally: each share contributes its expected hash +// count (target_to_average_attempts of its target), matching ShareIndex::work. +// This is the variant the v36 activation gate consumes — kept separate from the +// plain count above. +inline std::map +get_desired_version_weights(ShareChain& chain, const uint256& start_hash, uint64_t dist) +{ + std::map res; + if (!chain.contains(start_hash)) return res; + const uint64_t n = std::min(dist, + static_cast(chain.get_height(start_hash))); + for (auto&& [h, data] : chain.get_chain(start_hash, n)) { + (void)h; + data.share.invoke([&](auto* obj) { + using S = std::remove_pointer_t; + if constexpr (std::is_same_v) { + uint288 w = chain::target_to_average_attempts( + chain::bits_to_target(obj->m_bits)); + res[obj->m_desired_version] += w; + } + }); + } + return res; +} + +// 60% confirmed-state guard (p2pool-dash data.py Share.check). A SUCCESSOR +// share is accepted only when its PLAIN vote count reaches floor(total*60/100). +// The floor matches the oracle's integer `sum*60//100` exactly (e.g. 4/7 votes +// clears thr=4, 3/7 does not). +inline bool +successor_switch_allowed(const std::map& plain_counts, + uint64_t successor_version) +{ + uint64_t total = 0; + for (const auto& [v, c] : plain_counts) total += c; + if (total == 0) return false; + const uint64_t threshold = (total * 60) / 100; // floor, as in the oracle + auto it = plain_counts.find(successor_version); + const uint64_t have = (it == plain_counts.end()) ? 0 : it->second; + return have >= threshold; +} + +// v36 activation gate (p2pool-merged-v36 work.py): v36 is active once its +// WEIGHTED signaling reaches >= 95% of total work. Evaluated as the exact +// rational w36*100 >= total*95 — integer uint288, no IEEE-double fragility. +inline bool +v36_active(const std::map& weights, uint64_t v36_version = 36) +{ + uint288 total(0); + for (const auto& [v, w] : weights) total += w; + if (total == uint288(0)) return false; + auto it = weights.find(v36_version); + uint288 w36 = (it == weights.end()) ? uint288(0) : it->second; + return w36 * uint288(100) >= total * uint288(95); +} + +// Window helper mirroring Share.check: negotiation looks at the [9/10 .. 10/10] +// tail of the CHAIN_LENGTH ancestry behind `prev_hash`. Returns nullopt when +// fewer than `chain_length` ancestors exist ("switch without enough history"). +struct Window { uint256 start_hash; uint64_t dist; }; +inline std::optional +negotiation_window(ShareChain& chain, const uint256& prev_hash, uint64_t chain_length) +{ + if (chain.get_height(prev_hash) < static_cast(chain_length)) + return std::nullopt; + const uint64_t back = (chain_length * 9) / 10; + const uint64_t dist = chain_length / 10; + uint256 start = chain.get_nth_parent_key(prev_hash, static_cast(back)); + return Window{start, dist}; +} + +} // namespace dash::version_negotiation diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 63688847c..a3310bea5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -213,6 +213,20 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_block_replay PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39): core stratum/web_server TUs gtest_add_tests(test_dash_block_replay "" AUTO) + # DASH V36 conformance -- merkle-root-equality precondition (S6 slice). + # share_check.hpp (check_merkle_link) + coinbase_builder.hpp + # (merkle_branches_raw) are header-only over the dash_x11 + core link set; + # no ltc/pool SCC dependency. Mirrors the test_dash_header_chain link set. + add_executable(test_dash_conformance test_dash_conformance.cpp) + target_link_libraries(test_dash_conformance PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_dash_conformance PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39): core stratum/web_server TUs + gtest_add_tests(test_dash_conformance "" AUTO) + add_executable(test_compact_blocks test_compact_blocks.cpp) target_link_libraries(test_compact_blocks PRIVATE GTest::gtest_main GTest::gtest diff --git a/test/test_dash_conformance.cpp b/test/test_dash_conformance.cpp new file mode 100644 index 000000000..fb224f457 --- /dev/null +++ b/test/test_dash_conformance.cpp @@ -0,0 +1,647 @@ +// DASH V36 conformance — merkle-root-equality precondition (S6 slice). +// +// Before any share/block can be conformance-checked against DASH's own +// older-than-v35 oracle (frstrtr/p2pool-dash), one structural invariant must +// hold: the gentx hash plus the share's merkle_link must reconstruct the +// SAME block merkle root that a full-tree reduction of the transaction set +// produces. If that precondition fails, every downstream equality comparison +// is meaningless (you'd be comparing roots derived two different ways). +// +// This test pins that precondition WITHOUT a node dependency. It cross-checks +// the production path — +// dash::coinbase::merkle_branches_raw() (build the index-0 branch) +// dash::check_merkle_link() (walk gentx + branch -> root) +// — against an INDEPENDENT in-test reference reduction (canonical Bitcoin/ +// p2pool merkle: pairwise SHA256d, duplicate-last on odd). Two implementations +// agreeing is the invariant. +// +// The expected-root hex strings are KAT vectors computed OUT-OF-BAND with +// CPython hashlib (double-SHA256), so the pins are not circular with the C++ +// code under test. Leaves are sha256d(single byte i) so the fixtures are +// reproducible with a three-line script and carry no byte-order ambiguity +// (raw digest bytes == uint256 internal order == HexStr(GetChars())). +// +// NOTE: real captured-corpus KAT vectors from a live Dash node (VM200/201) +// are the S6 follow-on and gate on node-state-green; this slice locks the +// structural precondition those vectors will later exercise. + +#include + +#include // dash::coinbase::merkle_branches_raw, HexStr +#include // dash::check_merkle_link +#include // dash::MerkleLink +#include // dash::pplns::compute_payouts, dash::ShareChain, DashShare +#include // dash::version_negotiation::get_desired_version_counts/weights +#include // bitcoin_family::coin::SmallBlockHeaderType + +#include // Hash (sha256d) +#include + +#include +#include +#include +#include +#include + +namespace { + +// Leaf fixture: sha256d of a single byte. Matches CPython +// hashlib.sha256(hashlib.sha256(bytes([i])).digest()).digest() +uint256 leaf(uint8_t b) { + unsigned char x = b; + return Hash(std::span(&x, 1)); +} + +// Independent canonical merkle-root reduction (NOT the production walk): +// pairwise SHA256d over the whole layer, duplicate the last on odd width. +uint256 reference_root(std::vector layer) { + while (layer.size() > 1) { + if (layer.size() % 2 == 1) layer.push_back(layer.back()); + std::vector next; + next.reserve(layer.size() / 2); + for (size_t i = 0; i + 1 < layer.size(); i += 2) { + unsigned char buf[64]; + std::memcpy(buf, layer[i].data(), 32); + std::memcpy(buf + 32, layer[i + 1].data(), 32); + next.push_back(Hash(std::span(buf, 64))); + } + layer.swap(next); + } + return layer.empty() ? uint256() : layer[0]; +} + +std::string hex_internal(const uint256& h) { + auto c = h.GetChars(); + return HexStr(std::span(c.data(), c.size())); +} + +// Production path: build the index-0 branch and walk gentx (=leaf[0]) back. +uint256 production_root(const std::vector& txs) { + dash::MerkleLink link; + link.m_branch = dash::coinbase::merkle_branches_raw(txs); + link.m_index = 0; // coinbase / gentx is always at position 0 + return dash::check_merkle_link(txs[0], link); +} + +struct Kat { int n; const char* root_hex; }; + +// KAT vectors — CPython hashlib double-SHA256, internal (raw-digest) byte order. +const Kat KATS[] = { + {1, "1406e05881e299367766d313e26c05564ec91bf721d31726bd6e46e60689539a"}, + {2, "4bbe83bc38ebe2bcc7520d234139df1c0eb9ffa51f83eab1c5129b5b906b7655"}, + {3, "e129dfe02f567fc612d126596d43406144f40a771810ac7143421d2df3e5c1d0"}, + {5, "f4113849d628f7c3bc91cc0ff785a6aee3ee236c1c912b28cc09c44f9f97b748"}, + {7, "7de65c7d57cdc72971c9beab94af6ad4e99f233fb6ccebd2b4b19f13697ca54d"}, +}; + +} // namespace + +// Production walk == independent reference reduction, across tree shapes +// (1 leaf, even, odd/duplicate-last). This is the merkle-root-equality +// precondition itself. +TEST(DashConformanceMerkle, ProductionWalkMatchesReferenceReduction) { + for (const auto& k : KATS) { + std::vector txs; + for (int i = 0; i < k.n; ++i) txs.push_back(leaf(static_cast(i))); + EXPECT_EQ(production_root(txs), reference_root(txs)) + << "n=" << k.n << ": gentx+merkle_link did not reconstruct the full-tree root"; + } +} + +// Both paths must equal the out-of-band CPython KAT — locks byte order and +// guards against a coordinated regression in BOTH C++ implementations. +TEST(DashConformanceMerkle, MatchesOutOfBandKat) { + for (const auto& k : KATS) { + std::vector txs; + for (int i = 0; i < k.n; ++i) txs.push_back(leaf(static_cast(i))); + EXPECT_EQ(hex_internal(production_root(txs)), std::string(k.root_hex)) + << "n=" << k.n << ": production root != CPython KAT"; + EXPECT_EQ(hex_internal(reference_root(txs)), std::string(k.root_hex)) + << "n=" << k.n << ": reference root != CPython KAT"; + } +} + +// A single transaction (coinbase only): the gentx IS the merkle root, branch +// is empty, and the walk must be the identity. +TEST(DashConformanceMerkle, SingleTxRootIsGentx) { + std::vector txs{leaf(0)}; + EXPECT_TRUE(dash::coinbase::merkle_branches_raw(txs).empty()); + EXPECT_EQ(production_root(txs), txs[0]); +} + +// ── Payout-script-encoding conformance (S6 slice 2) ────────────────────────── +// Before any PPLNS payout SET can be conformance-checked against DASH's own +// older oracle (frstrtr/p2pool-dash data.py), each recipient's scriptPubKey +// must encode byte-identically. Dash payouts are ALWAYS P2PKH (no segwit): +// OP_DUP OP_HASH160 <0x14> <20-byte hash160> OP_EQUALVERIFY OP_CHECKSIG +// i.e. 76 a9 14 88 ac, total 25 bytes, with the hash emitted in uint160 +// internal (GetChars) order and NO reversal. These KATs pin that encoding with +// no node dependency; the donation cross-check proves two independent +// representations of the same recipient agree. +namespace { + +uint160 h160(const std::vector& v) { return uint160(v); } + +std::string hex_bytes(const std::vector& v) { + return HexStr(std::span(v.data(), v.size())); +} + +// p2pool-dash DONATION_SCRIPT hash160 (data.py) — the 20 bytes between the +// 76 a9 14 prefix and the 88 ac suffix of dash::DONATION_SCRIPT. +const std::vector DONATION_H160 = { + 0x20, 0xcb, 0x5c, 0x22, 0xb1, 0xe4, 0xd5, 0x94, + 0x7e, 0x5c, 0x11, 0x2c, 0x76, 0x96, 0xb5, 0x1a, + 0xd9, 0xaf, 0x3c, 0x61 +}; + +struct ScriptKat { std::vector h160; const char* script_hex; }; + +} // namespace + +// Canonical P2PKH shape for arbitrary hash160s, hash bytes in GetChars order. +TEST(DashConformancePayoutScript, CanonicalP2PKHStructure) { + const std::vector> hashes = { + std::vector(20, 0x00), + std::vector(20, 0xff), + DONATION_H160, + }; + for (const auto& hv : hashes) { + auto s = dash::pubkey_hash_to_script2(h160(hv)); + ASSERT_EQ(s.size(), 25u); + EXPECT_EQ(s[0], 0x76); EXPECT_EQ(s[1], 0xa9); EXPECT_EQ(s[2], 0x14); + EXPECT_EQ(s[23], 0x88); EXPECT_EQ(s[24], 0xac); + for (size_t i = 0; i < 20; ++i) + EXPECT_EQ(s[3 + i], hv[i]) << "hash byte " << i << " not in GetChars order"; + } +} + +// Out-of-band KAT: full 25-byte P2PKH script hex for fixed hash160s. +TEST(DashConformancePayoutScript, MatchesOutOfBandKat) { + const ScriptKat kats[] = { + { std::vector(20, 0x00), + "76a914000000000000000000000000000000000000000088ac" }, + { DONATION_H160, + "76a91420cb5c22b1e4d5947e5c112c7696b51ad9af3c6188ac" }, + }; + for (const auto& k : kats) + EXPECT_EQ(hex_bytes(dash::pubkey_hash_to_script2(h160(k.h160))), + std::string(k.script_hex)); +} + +// Two independent representations of the donation recipient agree: the literal +// DONATION_SCRIPT array (data.py copy) equals the script-builder over the +// donation hash160. Catches an accidental edit to either path. +TEST(DashConformancePayoutScript, DonationScriptIsP2PKHOverDonationHash) { + EXPECT_EQ(dash::pubkey_hash_to_script2(h160(DONATION_H160)), + dash::DONATION_SCRIPT); +} + +// ── Masternode-payment-packing conformance (S6 slice 3) ────────────────────── +// Dash's PPLNS payout set carries DASH-SPECIFIC entries BTC p2pool has no +// analogue for: masternode / superblock / platform payments, each packed as +// PackedPayment{ payee (PossiblyNone VarStr), amount (LE uint64) }. Before a +// full payout SET can be conformance-checked against frstrtr/p2pool-dash +// (data.py packed_payments), the single-entry and vector wire framing must +// match byte-for-byte: a CompactSize-prefixed payee string + 8-byte LE amount, +// and a CompactSize-prefixed vector count. These KATs are computed OUT-OF-BAND +// with CPython (CompactSize + struct "(ps); + return HexStr(std::span(reinterpret_cast(m.data()), m.size())); +} + +dash::PackedPayment payment(const std::string& payee, uint64_t amount) { + dash::PackedPayment pp; + pp.m_payee = payee; + pp.m_amount = amount; + return pp; +} + +} // namespace + +// Single PackedPayment: CompactSize(len) + payee bytes + LE64 amount. The empty +// payee is PossiblyNone(None) -> a single 0x00 length, NOT a sentinel. +TEST(DashConformancePayment, SingleEntryMatchesOutOfBandKat) { + struct PayKat { const char* payee; uint64_t amount; const char* hex; }; + const PayKat kats[] = { + {"", 0, "000000000000000000"}, + {"XyPmasternodePayeeAddr00", 200000000, + "185879506d61737465726e6f6465506179656541646472303000c2eb0b00000000"}, + {"!76a91420cb5c22b1e4d5947e5c112c7696b51ad9af3c6188ac", 500000000, + "332137366139313432306362356332326231653464353934376535633131326337" + "363936623531616439616633633631383861630065cd1d00000000"}, + }; + for (const auto& k : kats) { + PackStream ps; + ::Serialize(ps, payment(k.payee, k.amount)); + EXPECT_EQ(ps_hex(ps), std::string(k.hex)) + << "payee=\"" << k.payee << "\" amount=" << k.amount; + } +} + +// std::vector prefixes a CompactSize count, then each entry. +TEST(DashConformancePayment, VectorFramingMatchesOutOfBandKat) { + std::vector v{ + payment("XyPmasternodePayeeAddr00", 200000000), + payment("", 0), + }; + PackStream ps; + ::Serialize(ps, v); + EXPECT_EQ(ps_hex(ps), + std::string("02185879506d61737465726e6f6465506179656541646472303000c2eb0b" + "00000000000000000000000000")); +} + +// Unserialize is the exact inverse of Serialize (payee + amount recovered). +TEST(DashConformancePayment, RoundTripRecoversFields) { + const auto orig = payment("XyPmasternodePayeeAddr00", 200000000); + PackStream ps; + ::Serialize(ps, orig); + dash::PackedPayment back; + ::Unserialize(ps, back); + EXPECT_EQ(back.m_payee, orig.m_payee); + EXPECT_EQ(back.m_amount, orig.m_amount); +} + +// ── Share-header wire-equality conformance (S6 slice 4) ────────────────────── +// A DASH share embeds a compact min_header (bitcoin_family::coin:: +// SmallBlockHeaderType): CompactSize(version) + prev_block(32, internal order) +// + LE32 timestamp + LE32 bits + LE32 nonce. The VarInt(version) is c2pool's +// CompactFormat == Bitcoin CompactSize (0xfd/0xfe prefixes), the SAME encoding +// frstrtr/p2pool-dash's small_block_header_type uses for 'version'. Before a +// share header can be conformance-checked against that older-than-v35 oracle, +// this framing must match byte-for-byte. KATs are computed OUT-OF-BAND with +// CPython (CompactSize + struct " single byte + PackStream b; ::Serialize(b, small_header(253, 0, 0, 0, 0)); + EXPECT_EQ(ps_hex(b).substr(0, 6), std::string("fdfd00")); // 253 -> 0xfd + u16 LE +} + +// Unserialize is the exact inverse of Serialize (all five fields recovered). +TEST(DashConformanceShareHeader, RoundTripRecoversFields) { + const auto orig = small_header(70221, 3, 0x499602d2u, 0x1b0404cbu, 0xfeedfaceu); + PackStream ps; + ::Serialize(ps, orig); + bitcoin_family::coin::SmallBlockHeaderType back; + ::Unserialize(ps, back); + EXPECT_EQ(back.m_version, orig.m_version); + EXPECT_EQ(back.m_previous_block, orig.m_previous_block); + EXPECT_EQ(back.m_timestamp, orig.m_timestamp); + EXPECT_EQ(back.m_bits, orig.m_bits); + EXPECT_EQ(back.m_nonce, orig.m_nonce); +} + +// ── S6 conformance: nbits -> share difficulty equality vs frstrtr/p2pool-dash ── +// +// dash::coinbase::bits_to_difficulty() is the share-difficulty convention the +// pool advertises and that p2pool-dash peers expect. It must equal p2pool-dash +// bitcoin_data.target_to_difficulty(target): +// +// difficulty_1 = 0xffff * 2**208 ; difficulty = difficulty_1 / target +// +// Expected values are KAT vectors computed OUT-OF-BAND in CPython from that +// exact rational (big-int target via a re-implemented compact decode, then the +// p2pool ratio) — NOT from the C++ path — so the pins are not circular. The +// production path takes the top-64 target bits (target >> 192) and divides +// 0xffff0000 by them; for these vectors (compact size 0x1b..0x20) the targets +// significant bits never fall below bit 192, so that truncation is lossless and +// the two agree to the last ULP across diff 4.7e-10 .. 1.6e4. +TEST(DashConformanceDifficulty, BitsToDifficultyMatchesP2poolDash) { + struct DiffKat { uint32_t nbits; double diff; }; + const DiffKat kats[] = { + {0x1b104c8bu, 4020.7998157598363}, + {0x1e0ffff0u, 0.000244140625}, + {0x1d00ffffu, 1.0}, + {0x1b0404cbu, 16307.420938523983}, + {0x1c0fffffu, 15.999771117945784}, + {0x207fffffu, 4.6565423739069247e-10}, + }; + for (const auto& k : kats) { + const double got = dash::coinbase::bits_to_difficulty(k.nbits); + EXPECT_NEAR(got, k.diff, k.diff * 1e-12 + 1e-18) << "nbits=" << k.nbits; + } +} + +// ── PPLNS payout-SET equality conformance (S6 slice 6) ─────────────────────── +// The miner-facing output of the whole sharechain is the PPLNS payout SET: +// the {scriptPubKey -> amount} map a coinbase pays. For DASH to be value- +// equivalent to its own older oracle (frstrtr/p2pool-dash data.py +// get_expected_payouts) every recipient AND every satoshi must agree. This +// slice pins that floored-proportional split — per-share weight = difficulty +// (bits_to_difficulty), worker/donation split by m_donation/65535, dust drop, +// and the ALWAYS-emitted donation residue line — against OUT-OF-BAND CPython +// KAT vectors (no node dependency, not circular with the C++ doubles). +// +// The synthetic ShareChain is test-local scaffolding: minimal back-linked +// DashShares carrying only the fields compute_payouts() reads. bits is fixed +// to 0x1d00ffff, whose difficulty is EXACTLY 1.0 (difficulty_1 == target), so +// every weight is the integer 1.0 and the running sums are bit-exact in any +// order — the only floating ops left are the split fractions (e.g. 2.0/3.0), +// which IEEE-double C++ and CPython evaluate identically. That removes the +// summation-order fragility that would otherwise make the KAT non-reproducible. +namespace { + +// Compact bits whose Dash share difficulty is exactly 1.0 (see slice 5 KAT). +constexpr uint32_t BITS_DIFF1 = 0x1d00ffffu; + +uint256 pplns_share_hash(uint8_t tag) { + std::vector v(32, 0x00); + v[0] = tag; + v[31] = 0xa5; // keep non-null independent of tag + return uint256(v); +} + +uint160 miner_h160(uint8_t tag) { + std::vector v(20, 0x00); + v[0] = tag; + return uint160(v); +} + +// Owns a set of synthetic shares and the chain that indexes them. Shares are +// added tip-last; each links to `prev` via m_prev_hash. Only m_hash / +// m_prev_hash / m_bits / m_max_bits / m_donation / m_pubkey_hash are set. +struct SyntheticChain { + dash::ShareChain chain; + std::vector pool; // chain (ShareVariants::destroy) owns the shares; raw avoids double-free + + uint256 add(uint8_t tag, const uint256& prev, uint16_t donation, + const uint160& pkh) { + auto* s = new dash::DashShare(); + s->m_hash = pplns_share_hash(tag); + s->m_prev_hash = prev; + s->m_bits = BITS_DIFF1; + s->m_max_bits = BITS_DIFF1; // ShareIndex ctor reads m_max_bits + s->m_donation = donation; + s->m_pubkey_hash = pkh; + const uint256 h = s->m_hash; + chain.add(s); + pool.push_back(s); + return h; + } +}; + +// Flatten a payout result into {script_hex -> amount} for set comparison, and +// separately assert the outputs are sorted ascending by script bytes (the +// deterministic coinbase-output order compute_payouts() guarantees). +std::map payout_set(const dash::pplns::Result& r) { + std::map out; + for (const auto& p : r.payouts) + out[HexStr(std::span(p.script.data(), p.script.size()))] + = p.amount; + return out; +} + +std::string script_hex(const std::vector& v) { + return HexStr(std::span(v.data(), v.size())); +} + +} // namespace + +// Two miners, A with two shares and B with one (all weight 1.0), V = 1 DASH. +// p2pool floored split: A = floor(2/3 * 1e8) = 66666666, B = floor(1/3 * 1e8) +// = 33333333, donation residue = 1. Outputs sorted by script bytes (A < B < +// donation). KAT amounts computed OUT-OF-BAND in CPython. +TEST(DashConformancePplns, ProportionalSplitMatchesOutOfBandKat) { + SyntheticChain sc; + const uint160 A = miner_h160(0x01), B = miner_h160(0x02); + uint256 g = sc.add(0x10, uint256(), 0, B); // genesis: miner B + uint256 s1 = sc.add(0x11, g, 0, A); // miner A + uint256 tip = sc.add(0x12, s1, 0, A); // tip: miner A + + const uint64_t V = 100000000; // 1 DASH + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xee)); + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback, /*min_shares*/ 2); + + ASSERT_FALSE(r.used_fallback); + EXPECT_EQ(r.shares_used, 3u); + + const std::string a_hex = script_hex(dash::pubkey_hash_to_script2(A)); + const std::string b_hex = script_hex(dash::pubkey_hash_to_script2(B)); + const std::string d_hex = script_hex(dash::DONATION_SCRIPT); + + const std::map expected = { + {a_hex, 66666666u}, + {b_hex, 33333333u}, + {d_hex, 1u}, + }; + EXPECT_EQ(payout_set(r), expected); + + // Conservation: every satoshi of miner_value is accounted for. + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); + + // Deterministic order: outputs sorted ascending by script bytes. + for (size_t i = 1; i < r.payouts.size(); ++i) + EXPECT_TRUE(r.payouts[i - 1].script < r.payouts[i].script) + << "payout outputs not in canonical script order at index " << i; +} + +// A single miner share with a ~10% donation (m_donation = 6553/65535). The +// worker keeps frac = 1 - 6553/65535 of the value; the unallocated remainder +// (donation portion + rounding) flows to the always-emitted donation line. +// CPython KAT: A = floor((1 - 6553/65535) * 1e8) = 90000762, donation = 9999238. +TEST(DashConformancePplns, DonationWeightSplitMatchesOutOfBandKat) { + SyntheticChain sc; + const uint160 A = miner_h160(0x01); + uint256 tip = sc.add(0x20, uint256(), /*donation bps*/ 6553, A); + + const uint64_t V = 100000000; + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xee)); + // min_shares = 1 so a single-share chain takes the PPLNS path, not fallback. + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback, /*min_shares*/ 1); + + ASSERT_FALSE(r.used_fallback); + const std::map expected = { + {script_hex(dash::pubkey_hash_to_script2(A)), 90000762u}, + {script_hex(dash::DONATION_SCRIPT), 9999238u}, + }; + EXPECT_EQ(payout_set(r), expected); + + uint64_t sum = 0; + for (const auto& p : r.payouts) sum += p.amount; + EXPECT_EQ(sum, V); +} + +// Cold / insufficient chain: when fewer than min_shares ancestors are +// reachable, p2pool mines solo — a single payout of the full value to the +// caller's fallback script. Tip absent from the chain hits the same path. +TEST(DashConformancePplns, ColdChainFallsBackToSingleRecipient) { + SyntheticChain sc; + const uint160 A = miner_h160(0x01); + uint256 tip = sc.add(0x30, uint256(), 0, A); // only one share + + const uint64_t V = 100000000; + auto fallback = dash::pubkey_hash_to_script2(miner_h160(0xee)); + + // Default min_shares (20) is unmet by a 1-share chain -> fallback. + auto r = dash::pplns::compute_payouts(sc.chain, tip, /*window*/ 10, V, + fallback); + ASSERT_TRUE(r.used_fallback); + ASSERT_EQ(r.payouts.size(), 1u); + EXPECT_EQ(r.payouts[0].script, fallback); + EXPECT_EQ(r.payouts[0].amount, V); + + // A tip that isn't in the chain at all also falls back. + auto r2 = dash::pplns::compute_payouts(sc.chain, pplns_share_hash(0x7f), + 10, V, fallback, 1); + ASSERT_TRUE(r2.used_fallback); + ASSERT_EQ(r2.payouts.size(), 1u); + EXPECT_EQ(r2.payouts[0].amount, V); +} + +// ── S6 conformance: share-version transition negotiation vs frstrtr/p2pool-dash ── +// +// The older-than-v35 -> v36 handshake. Two tallies are kept deliberately apart +// (the F10 version-gate trap): a PLAIN per-share vote count drives the +// SUCCESSOR confirmed-state guard / AutoRatchet, while a SEPARATE work-WEIGHTED +// tally drives the v36 activation gate. Mixing them is the exact divergence the +// fleet is converging on for V36. +// +// Reference: p2pool-dash data.py Share.check (60% SUCCESSOR guard, plain +// get_desired_version_counts) and p2pool-merged-v36 work.py (v36_active = +// weight[36]/Sum >= 0.95). All thresholds and weights below are KAT vectors +// computed OUT-OF-BAND in CPython from the exact integer formulas +// target_to_average_attempts = 2**256 // (target + 1) +// tail-guard : count >= (total*60)//100 (floor) +// v36 gate : w36*100 >= total*95 (exact rational) +// so the pins are not circular with the C++ path. + +// Out-of-band CPython: diff-1 (0x1d00ffff) work = 2**256//(target+1). +static constexpr uint64_t W_DIFF1 = 4295032833ULL; // 0x100010001 +// Heavier share, bits 0x1c7fff00 (target = 0x7fff00 << (8*25)): work doubles+. +static constexpr uint32_t BITS_HEAVY = 0x1c7fff00u; +static constexpr uint64_t W_HEAVY = 8590196744ULL; // 0x200040008 + +namespace { +// Append a share with an explicit desired_version and bits. SyntheticChain's +// ShareIndex caches work from m_bits at add() time; version_negotiation reads +// obj->m_bits live, so post-add patching is what the production walk observes. +uint256 add_versioned(SyntheticChain& sc, uint8_t tag, const uint256& prev, + uint64_t version, uint32_t bits, const uint160& pkh) { + uint256 h = sc.add(tag, prev, /*donation*/ 0, pkh); + auto* s = sc.pool.back(); + s->m_desired_version = version; + s->m_bits = bits; + return h; +} +} // namespace + +// Production plain count and work-weighted tally over the same window must equal +// the out-of-band CPython KATs: a 3x-v36(diff1) + 1x-v16(heavy) chain. +TEST(DashConformanceVersionNeg, PlainCountAndWeightVsOutOfBandKat) { + SyntheticChain sc; + const uint160 A = miner_h160(0x01); + uint256 g = add_versioned(sc, 0x40, uint256(), 16, BITS_HEAVY, A); // old, heavy + uint256 s1 = add_versioned(sc, 0x41, g, 36, BITS_DIFF1, A); + uint256 s2 = add_versioned(sc, 0x42, s1, 36, BITS_DIFF1, A); + uint256 tip = add_versioned(sc, 0x43, s2, 36, BITS_DIFF1, A); + + auto plain = dash::version_negotiation::get_desired_version_counts(sc.chain, tip, 4); + const std::map plain_expected = {{16u, 1u}, {36u, 3u}}; + EXPECT_EQ(plain, plain_expected) << "plain vote count != CPython KAT"; + + auto weights = dash::version_negotiation::get_desired_version_weights(sc.chain, tip, 4); + ASSERT_TRUE(weights.contains(16u) && weights.contains(36u)); + EXPECT_TRUE(weights[16u] == uint288(W_HEAVY)) + << "v16 weight != CPython KAT (heavy share work)"; + EXPECT_TRUE(weights[36u] == uint288(3ULL * W_DIFF1)) + << "v36 weight != CPython KAT (3x diff1 work)"; +} + +// 60% SUCCESSOR tail-guard, floor semantics. KAT booleans from CPython +// count >= (total*60)//100. The 4/7 case (57% but thr=floor(4.2)=4) and 3/7 +// case lock the floor exactly as the oracle's `sum*60//100`. +TEST(DashConformanceVersionNeg, SuccessorGuard60PercentFloorKat) { + using dash::version_negotiation::successor_switch_allowed; + EXPECT_TRUE (successor_switch_allowed({{36u, 6u}, {16u, 4u}}, 36u)); // 60% + EXPECT_FALSE(successor_switch_allowed({{36u, 5u}, {16u, 5u}}, 36u)); // 50% + EXPECT_TRUE (successor_switch_allowed({{36u, 60u}, {16u, 40u}}, 36u)); // 60% + EXPECT_FALSE(successor_switch_allowed({{36u, 59u}, {16u, 41u}}, 36u)); // 59% + EXPECT_TRUE (successor_switch_allowed({{36u, 4u}, {16u, 3u}}, 36u)); // 4/7, thr=4 + EXPECT_FALSE(successor_switch_allowed({{36u, 3u}, {16u, 4u}}, 36u)); // 3/7, thr=4 + EXPECT_FALSE(successor_switch_allowed({}, 36u)); // empty +} + +// v36 activation gate, exact-rational 95% on the work-weighted tally. KAT +// booleans from CPython w36*100 >= total*95. +TEST(DashConformanceVersionNeg, V36GateWeighted95PercentKat) { + using dash::version_negotiation::v36_active; + EXPECT_TRUE (v36_active({{36u, uint288(95u)}, {16u, uint288(5u)}})); // exactly 95% + EXPECT_FALSE(v36_active({{36u, uint288(94u)}, {16u, uint288(6u)}})); // 94% + EXPECT_TRUE (v36_active({{36u, uint288(19u)}, {16u, uint288(1u)}})); // 19/20 = 95% + EXPECT_FALSE(v36_active({{36u, uint288(18u)}, {16u, uint288(2u)}})); // 18/20 = 90% + EXPECT_FALSE(v36_active({{16u, uint288(100u)}})); // no v36 vote + + // Wide-value boundary: exercises the uint288 path at scale (no overflow, + // exactly 95%). big = 2^240. + uint288 big(1u); big <<= 240; + EXPECT_TRUE (v36_active({{36u, big * uint288(95u)}, {16u, big * uint288(5u)}})); + EXPECT_FALSE(v36_active({{36u, big * uint288(94u)}, {16u, big * uint288(6u)}})); +} + +// The two tallies must diverge: on the 3x-v36(diff1)+1x-v16(heavy) chain the +// PLAIN guard clears (v36 holds 3/4 of votes >= 60%) but the WEIGHTED gate +// denies (v36 holds only ~60% of work < 95%). Proves the gate consumes the +// weighted variant, not the plain count (F10 separation). +TEST(DashConformanceVersionNeg, GateUsesWeightNotPlainCount) { + SyntheticChain sc; + const uint160 A = miner_h160(0x02); + uint256 g = add_versioned(sc, 0x50, uint256(), 16, BITS_HEAVY, A); + uint256 s1 = add_versioned(sc, 0x51, g, 36, BITS_DIFF1, A); + uint256 s2 = add_versioned(sc, 0x52, s1, 36, BITS_DIFF1, A); + uint256 tip = add_versioned(sc, 0x53, s2, 36, BITS_DIFF1, A); + + auto plain = dash::version_negotiation::get_desired_version_counts(sc.chain, tip, 4); + auto weights = dash::version_negotiation::get_desired_version_weights(sc.chain, tip, 4); + + // Plain: v36 = 3/4 of votes -> SUCCESSOR switch permitted. + EXPECT_TRUE(dash::version_negotiation::successor_switch_allowed(plain, 36u)); + // Weighted: v36 work share ~60% (3*W_DIFF1 / (3*W_DIFF1 + W_HEAVY)) < 95%. + EXPECT_FALSE(dash::version_negotiation::v36_active(weights)); +}