diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index daca62dae..b58712be8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,7 +72,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_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum \ + test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root \ test_multiaddress_pplns test_pplns_stress \ test_hash_link test_decay_pplns \ test_pplns_consensus \ @@ -205,7 +205,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_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum \ + test_dash_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum test_dash_quorum_root \ test_hash_link test_decay_pplns \ test_pplns_consensus \ test_v36_script_sorting test_v36_cross_impl_refhash \ diff --git a/src/impl/dash/coin/quorum_root.hpp b/src/impl/dash/coin/quorum_root.hpp new file mode 100644 index 000000000..8fb4031b4 --- /dev/null +++ b/src/impl/dash/coin/quorum_root.hpp @@ -0,0 +1,172 @@ +#pragma once + +/// Phase C-TEMPLATE step 4c: vendor compute_merkle_root_quorums. +/// +/// Mirrors dashcore evo/cbtx.cpp::CalcCbTxMerkleRootQuorums @ cfad414 +/// using our QuorumManager state as input. Per-type ordering matches +/// dashcore's GetMinedCommitmentsUntilBlock semantics: for non-rotated +/// LLMQ types, hashes ordered NEWEST-FIRST by mining_height; for +/// rotated types, hashes per quorumIndex (latest mining_height per +/// index). +/// +/// Algorithm: +/// 1. Group active entries by llmqType. +/// 2. Per non-rotated type: sort by mining_height DESC, take all +/// (mnlistdiff already enforced signingActiveQuorumCount cap). +/// 3. Per rotated type: group by quorumIndex, take latest per index +/// by mining_height. +/// 4. For each entry: hash = SHA256d(pack(commitment)) +/// (= dashcore's SerializeHash(commitment) — NOT the wrapping +/// CFinalCommitmentTxPayload). +/// 5. Concatenate per-type hash lists into vec_hashes_final +/// (std::map iteration = ascending llmqType, matching dashcore's +/// `for (const auto& [llmqType, vec] : qcHashes)` pattern). +/// 6. Sort vec_hashes_final lexicographically using std::memcmp +/// (matches dashcore's uint256 operator< = LE-byte comparison; +/// same gotcha as Bug A in vendor/simplifiedmns.hpp's sort). +/// 7. Compute Bitcoin/Dash merkle root over vec_hashes_final. +/// +/// Limitations vs dashcore-bit-exact: +/// - We use OUR active set as of last applied mnlistdiff. Doesn't +/// include qfcommit additions from blocks not yet covered by +/// mnlistdiff (e.g., the current block being validated). For +/// [QUORUMS-XCHECK] log-only this means MISMATCH may fire on +/// blocks at or just past tip during catch-up. +/// - Entries with mining_height=0 (un-observed qfcommit, e.g. +/// pre-checkpoint quorums) sort to the END (oldest-equivalent). +/// If real ordering differs, MISMATCH surfaces it. +/// +/// LLMQ params hardcoded for Dash mainnet (consensus/llmq.cpp): +/// type 1 LLMQ_50_60 useRotation=false signingCount=24 +/// type 2 LLMQ_400_60 useRotation=false signingCount=4 +/// type 3 LLMQ_400_85 useRotation=false signingCount=4 +/// type 4 LLMQ_100_67 useRotation=false signingCount=24 +/// type 5 LLMQ_60_75 useRotation=true signingCount=32 +/// type 6 LLMQ_25_67 useRotation=true signingCount=24 + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { + +inline bool llmq_uses_rotation(uint8_t llmqType) +{ + // Mainnet rotation flags: types 5 (LLMQ_60_75) and 6 (LLMQ_25_67). + return llmqType == 5 || llmqType == 6; +} + +/// SHA256d of pack(commitment). Equivalent to dashcore's +/// SerializeHash(commitment) where commitment is CFinalCommitment +/// (NOT the wrapping CFinalCommitmentTxPayload). +inline uint256 hash_commitment(const vendor::CFinalCommitment& c) +{ + auto stream = ::pack(c); + auto sp = stream.get_span(); + uint256 h; + CHash256() + .Write(std::span( + reinterpret_cast(sp.data()), sp.size())) + .Finalize(std::span(h.data(), 32)); + return h; +} + +/// Standard Bitcoin/Dash SHA256d-pairwise merkle root with +/// duplicate-last-on-odd. Same as the helper inlined in +/// vendor/simplifiedmns.hpp. +inline uint256 merkle_pair_hash(const uint256& a, const uint256& b) +{ + uint256 out; + CHash256() + .Write(std::span(a.data(), 32)) + .Write(std::span(b.data(), 32)) + .Finalize(std::span(out.data(), 32)); + return out; +} + +inline uint256 compute_merkle_root_local(std::vector hashes) +{ + if (hashes.empty()) return uint256::ZERO; + while (hashes.size() > 1) { + if (hashes.size() & 1u) hashes.push_back(hashes.back()); + std::vector next; + next.reserve(hashes.size() / 2); + for (size_t i = 0; i < hashes.size(); i += 2) { + next.push_back(merkle_pair_hash(hashes[i], hashes[i + 1])); + } + hashes = std::move(next); + } + return hashes[0]; +} + +inline uint256 compute_merkle_root_quorums(const QuorumManager& qmgr) +{ + // Group entries by llmqType. + std::map> by_type; + for (const auto& e : qmgr.active_entries()) { + by_type[e.key.llmqType].push_back(&e); + } + + std::vector vec_hashes_final; + vec_hashes_final.reserve(qmgr.active_entries().size()); + + for (auto& [llmqType, entries] : by_type) { + if (llmq_uses_rotation(llmqType)) { + // Group by quorumIndex; pick latest mining_height per + // index. dashcore iterates std::map = sorted + // by quorumIndex ascending. + std::map by_index; + for (auto* ep : entries) { + int16_t qi = ep->commitment.quorumIndex; + auto it = by_index.find(qi); + if (it == by_index.end() + || ep->mining_height > it->second->mining_height) { + by_index[qi] = ep; + } + } + for (auto& [_, ep] : by_index) { + vec_hashes_final.push_back(hash_commitment(ep->commitment)); + } + } else { + // Non-rotated: sort by mining_height DESCENDING (newest + // first). Tiebreak by quorumHash memcmp (deterministic + // for entries with mining_height=0 / unknown). + std::sort(entries.begin(), entries.end(), + [](const QuorumManager::Entry* a, + const QuorumManager::Entry* b) { + if (a->mining_height != b->mining_height) + return a->mining_height > b->mining_height; + return std::memcmp(a->key.quorumHash.data(), + b->key.quorumHash.data(), 32) < 0; + }); + for (auto* ep : entries) { + vec_hashes_final.push_back(hash_commitment(ep->commitment)); + } + } + } + + // Final sort: lexicographic by uint256 (memcmp = dashcore's + // uint256 operator< for the `std::sort(vec_hashes_final.begin(), + // vec_hashes_final.end())` call at cbtx.cpp:192). + std::sort(vec_hashes_final.begin(), vec_hashes_final.end(), + [](const uint256& a, const uint256& b) { + return std::memcmp(a.data(), b.data(), 32) < 0; + }); + + return compute_merkle_root_local(std::move(vec_hashes_final)); +} + +} // namespace coin +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e78d8845d..3d3af7693 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -310,6 +310,19 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_quorum PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) gtest_add_tests(test_dash_quorum "" AUTO) + # Header-only merkleRootQuorums (CbTx) computation leaf over the + # QuorumManager active set; no ltc/pool SCC dependency. Sits between + # the LLMQ quorum-tail leaf (#321) and embedded_gbt main (S7). + add_executable(test_dash_quorum_root test_dash_quorum_root.cpp) + target_link_libraries(test_dash_quorum_root PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + target_link_libraries(test_dash_quorum_root PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) + gtest_add_tests(test_dash_quorum_root "" 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_quorum_root.cpp b/test/test_dash_quorum_root.cpp new file mode 100644 index 000000000..d806fc6da --- /dev/null +++ b/test/test_dash_quorum_root.cpp @@ -0,0 +1,334 @@ +/// Phase C-TEMPLATE step 4c — Dash merkleRootQuorums computation leaf unit tests +/// +/// Exercises the vendored CalcCbTxMerkleRootQuorums mirror: +/// +/// - src/impl/dash/coin/quorum_root.hpp +/// +/// This is the quorum-root leaf that sits between the LLMQ +/// quorum-tail verification leaf (#321) and the embedded_gbt main: it +/// pins how the in-memory QuorumManager active set is reduced to the +/// CbTx `merkleRootQuorums` field that a built block template must carry +/// for a Dash node to accept the coinbase special-tx payload. +/// +/// KATs pinned here (all self-contained, bit-exact against the library +/// SHA256d primitive): +/// - llmq_uses_rotation() flags exactly the mainnet rotated types +/// (5 = LLMQ_60_75, 6 = LLMQ_25_67) and nothing else. +/// - hash_commitment() == SHA256d(pack(CFinalCommitment)) for a fully +/// specified non-indexed commitment, frozen against a wire-byte +/// golden so a pack/serialize drift surfaces. +/// - merkle_pair_hash() / compute_merkle_root_local() match the +/// standard Bitcoin/Dash duplicate-last-on-odd SHA256d merkle: +/// empty -> ZERO, singleton -> itself, pair, odd-triple. +/// - compute_merkle_root_quorums() includes EVERY non-rotated active +/// entry, applies the rotated latest-per-quorumIndex dedup (older +/// same-index entries are dropped from the set), and the final root +/// is the lexicographically-sorted SHA256d merkle over that set. +/// Cross-checked against an independent reference that re-derives the +/// included set by hand and against frozen root goldens. +/// +/// SCOPE NOTE (honest): the end-to-end "active set built from a live +/// Dash mnlistdiff reproduces the on-chain cbTx.merkleRootQuorums of a +/// real block" cross-check is explicitly NOT claimed here — that needs +/// the full mnlistdiff bootstrap and is deferred to Phase L, exactly as +/// the #321 quorum-tail leaf deferred BLS verification. The block +/// fixtures carry the on-chain merkleRootQuorums for that future check. + +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using dash::coin::QuorumManager; +using dash::coin::vendor::CFinalCommitment; +using dash::coin::llmq_uses_rotation; +using dash::coin::hash_commitment; +using dash::coin::merkle_pair_hash; +using dash::coin::compute_merkle_root_local; +using dash::coin::compute_merkle_root_quorums; + +namespace { + +// Hex of a uint256 in display order (reversed bytes), to keep frozen +// goldens human-comparable with dashcore/explorer output. +std::string to_hex_rev(const uint256& h) +{ + static const char* k = "0123456789abcdef"; + std::string s; + s.reserve(64); + for (int i = 31; i >= 0; --i) { + s.push_back(k[h.data()[i] >> 4]); + s.push_back(k[h.data()[i] & 0xf]); + } + return s; +} + +// Independent SHA256d over an arbitrary byte span (does not go through +// quorum_root.hpp) — the reference oracle for the merkle KATs. +uint256 sha256d(std::span in) +{ + uint256 out; + CHash256() + .Write(in) + .Finalize(std::span(out.data(), 32)); + return out; +} + +uint256 hash256_concat(const uint256& a, const uint256& b) +{ + std::array buf{}; + std::memcpy(buf.data(), a.data(), 32); + std::memcpy(buf.data() + 32, b.data(), 32); + return sha256d(std::span(buf.data(), 64)); +} + +uint256 hash_of_byte(unsigned char b) +{ + return sha256d(std::span(&b, 1)); +} + +// Build a fully-specified non-indexed (v1) commitment with caller-chosen +// llmqType / quorumHash / quorumIndex so the active-set tests can +// distinguish entries by their serialized bytes. +CFinalCommitment make_commitment(uint8_t llmqType, unsigned char hash_seed, + int16_t quorumIndex) +{ + CFinalCommitment c; + c.nVersion = CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION; + c.llmqType = llmqType; + for (int i = 0; i < 32; ++i) c.quorumHash.data()[i] = hash_seed; + c.quorumIndex = quorumIndex; // carried in memory even for v1 + c.signers.assign(8, false); + c.validMembers.assign(8, true); + // quorumPublicKey / quorumSig / membersSig left zero-initialized. + return c; +} + +QuorumManager::Entry make_entry(uint8_t llmqType, unsigned char hash_seed, + int16_t quorumIndex, uint32_t mining_height) +{ + QuorumManager::Entry e; + e.commitment = make_commitment(llmqType, hash_seed, quorumIndex); + e.key = QuorumManager::ActiveKey{llmqType, e.commitment.quorumHash}; + e.mining_height = mining_height; + return e; +} + +// Reference re-implementation of the SET selected by +// compute_merkle_root_quorums: every non-rotated entry, plus the +// latest-mining-height entry per (rotated type, quorumIndex). Returns +// the lexicographically-sorted commitment hashes — the leaves that feed +// the merkle root. Deliberately independent of the per-type pre-sort in +// quorum_root.hpp (which the final lexicographic sort makes irrelevant +// to the root), so this pins the SET-selection consensus behavior. +std::vector reference_leaves(const QuorumManager& q) +{ + std::vector selected; + // non-rotated: all. + for (const auto& e : q.active_entries()) { + if (!llmq_uses_rotation(e.key.llmqType)) selected.push_back(&e); + } + // rotated: latest mining_height per (type, quorumIndex). + std::vector rotated; + for (const auto& e : q.active_entries()) { + if (llmq_uses_rotation(e.key.llmqType)) rotated.push_back(&e); + } + for (auto* ep : rotated) { + bool superseded = false; + for (auto* other : rotated) { + if (other == ep) continue; + if (other->key.llmqType == ep->key.llmqType + && other->commitment.quorumIndex == ep->commitment.quorumIndex + && other->mining_height > ep->mining_height) { + superseded = true; + break; + } + } + if (!superseded) selected.push_back(ep); + } + std::vector leaves; + for (auto* ep : selected) leaves.push_back(hash_commitment(ep->commitment)); + std::sort(leaves.begin(), leaves.end(), + [](const uint256& a, const uint256& b) { + return std::memcmp(a.data(), b.data(), 32) < 0; + }); + return leaves; +} + +} // namespace + +// --------------------------------------------------------------------- +// llmq_uses_rotation +// --------------------------------------------------------------------- + +TEST(DashQuorumRootKat, RotationFlagsMatchMainnet) +{ + EXPECT_FALSE(llmq_uses_rotation(CFinalCommitment::LLMQ_50_60)); // 1 + EXPECT_FALSE(llmq_uses_rotation(CFinalCommitment::LLMQ_400_60)); // 2 + EXPECT_FALSE(llmq_uses_rotation(CFinalCommitment::LLMQ_400_85)); // 3 + EXPECT_FALSE(llmq_uses_rotation(CFinalCommitment::LLMQ_100_67)); // 4 + EXPECT_TRUE(llmq_uses_rotation(CFinalCommitment::LLMQ_60_75)); // 5 + EXPECT_TRUE(llmq_uses_rotation(CFinalCommitment::LLMQ_25_67)); // 6 + EXPECT_FALSE(llmq_uses_rotation(CFinalCommitment::LLMQ_NONE)); // 0xff + EXPECT_FALSE(llmq_uses_rotation(0)); + EXPECT_FALSE(llmq_uses_rotation(7)); +} + +// --------------------------------------------------------------------- +// hash_commitment — bit-exact vs an independent SHA256d(pack(c)) +// --------------------------------------------------------------------- + +TEST(DashQuorumRootKat, HashCommitmentMatchesSerializeHash) +{ + CFinalCommitment c = make_commitment(CFinalCommitment::LLMQ_50_60, + /*hash_seed*/ 0xab, /*qi*/ 0); + + auto stream = ::pack(c); + auto sp = stream.get_span(); + uint256 expected = sha256d(std::span( + reinterpret_cast(sp.data()), sp.size())); + + EXPECT_EQ(hash_commitment(c), expected); + + // Frozen wire-byte golden (display/reversed hex). A change here means + // CFinalCommitment serialization drifted — review before re-pinning. + EXPECT_EQ(to_hex_rev(hash_commitment(c)), + "484866e842b8b7567d91c64a0b25c2d7e856f0320fc292c2cf7825aca6e4ade4"); +} + +// --------------------------------------------------------------------- +// merkle primitives +// --------------------------------------------------------------------- + +TEST(DashQuorumRootKat, MerkleEmptyIsZero) +{ + EXPECT_EQ(compute_merkle_root_local({}), uint256::ZERO); +} + +TEST(DashQuorumRootKat, MerkleSingletonIsItself) +{ + uint256 a = hash_of_byte(0x01); + EXPECT_EQ(compute_merkle_root_local({a}), a); +} + +TEST(DashQuorumRootKat, MerklePairMatchesConcatHash) +{ + uint256 a = hash_of_byte(0x01); + uint256 b = hash_of_byte(0x02); + EXPECT_EQ(merkle_pair_hash(a, b), hash256_concat(a, b)); + EXPECT_EQ(compute_merkle_root_local({a, b}), hash256_concat(a, b)); +} + +TEST(DashQuorumRootKat, MerkleOddTripleDuplicatesLast) +{ + uint256 a = hash_of_byte(0x01); + uint256 b = hash_of_byte(0x02); + uint256 c = hash_of_byte(0x03); + // level1: H(a,b), H(c,c) ; root: H(H(a,b), H(c,c)) + uint256 ab = hash256_concat(a, b); + uint256 cc = hash256_concat(c, c); + EXPECT_EQ(compute_merkle_root_local({a, b, c}), hash256_concat(ab, cc)); +} + +// --------------------------------------------------------------------- +// compute_merkle_root_quorums — set selection + ordering +// --------------------------------------------------------------------- + +TEST(DashQuorumRootKat, EmptyManagerRootIsZero) +{ + QuorumManager q; + EXPECT_EQ(compute_merkle_root_quorums(q), uint256::ZERO); +} + +TEST(DashQuorumRootKat, NonRotatedIncludesAllEntriesSorted) +{ + QuorumManager q; + std::vector active; + active.push_back(make_entry(CFinalCommitment::LLMQ_50_60, 0x11, 0, 100)); + active.push_back(make_entry(CFinalCommitment::LLMQ_400_60, 0x22, 0, 90)); + active.push_back(make_entry(CFinalCommitment::LLMQ_100_67, 0x33, 0, 110)); + q.replace_state(std::move(active), {}); + + EXPECT_EQ(compute_merkle_root_quorums(q), + compute_merkle_root_local(reference_leaves(q))); + // All three included -> root is non-zero and depends on the set. + EXPECT_NE(compute_merkle_root_quorums(q), uint256::ZERO); +} + +TEST(DashQuorumRootKat, RotatedDedupKeepsLatestPerIndex) +{ + // Two rotated (type 5) entries share quorumIndex 0; the older one + // (mining_height 50) must be dropped, the newer (80) kept. A third + // at quorumIndex 1 is independent and kept. + QuorumManager q; + std::vector active; + active.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xaa, /*qi*/0, 50)); + active.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xbb, /*qi*/0, 80)); + active.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xcc, /*qi*/1, 70)); + q.replace_state(std::move(active), {}); + + auto leaves = reference_leaves(q); + EXPECT_EQ(leaves.size(), 2u); // older qi=0 dropped + EXPECT_EQ(compute_merkle_root_quorums(q), + compute_merkle_root_local(leaves)); + + // The dropped older entry must NOT influence the root: a manager + // holding only {newer qi=0, qi=1} yields the same root. + QuorumManager q2; + std::vector active2; + active2.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xbb, 0, 80)); + active2.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xcc, 1, 70)); + q2.replace_state(std::move(active2), {}); + EXPECT_EQ(compute_merkle_root_quorums(q), compute_merkle_root_quorums(q2)); +} + +TEST(DashQuorumRootKat, AddingDistinctIndexChangesRoot) +{ + QuorumManager q; + std::vector a1; + a1.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xbb, 0, 80)); + q.replace_state(std::move(a1), {}); + uint256 before = compute_merkle_root_quorums(q); + + QuorumManager q2; + std::vector a2; + a2.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xbb, 0, 80)); + a2.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xcc, 1, 70)); + q2.replace_state(std::move(a2), {}); + EXPECT_NE(before, compute_merkle_root_quorums(q2)); +} + +TEST(DashQuorumRootKat, MixedRotatedAndNonRotatedFrozenGolden) +{ + QuorumManager q; + std::vector active; + active.push_back(make_entry(CFinalCommitment::LLMQ_50_60, 0x11, 0, 100)); + active.push_back(make_entry(CFinalCommitment::LLMQ_400_85, 0x22, 0, 95)); + active.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xaa, 0, 50)); // dropped + active.push_back(make_entry(CFinalCommitment::LLMQ_60_75, 0xbb, 0, 80)); // kept + active.push_back(make_entry(CFinalCommitment::LLMQ_25_67, 0xcc, 3, 70)); + q.replace_state(std::move(active), {}); + + auto leaves = reference_leaves(q); + EXPECT_EQ(leaves.size(), 4u); // 2 non-rotated + 2 rotated (one deduped) + uint256 root = compute_merkle_root_quorums(q); + EXPECT_EQ(root, compute_merkle_root_local(leaves)); + + // Frozen golden (display/reversed hex). Drift => set-selection or + // serialization changed; re-pin only after verifying intentional. + EXPECT_EQ(to_hex_rev(root), + "2cc59f74a7c40646968dafddcfba9759bc66a327c83695b304984f84dbeb0c37"); +}