diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c511bdc32..8d394ff69 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_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum \ 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_header_chain test_dash_block_replay test_dash_conformance test_dash_subsidy test_dash_mempool test_dash_simplifiedmns test_dash_quorum \ 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_manager.hpp b/src/impl/dash/coin/quorum_manager.hpp new file mode 100644 index 000000000..1e66f8893 --- /dev/null +++ b/src/impl/dash/coin/quorum_manager.hpp @@ -0,0 +1,221 @@ +#pragma once + +// QuorumManager: in-memory tracker for the active LLMQ quorum set. +// Phase C-QUO step 3. +// +// Consumes the structured QuorumTail from each accepted mnlistdiff: +// - Erase entries whose (llmqType, quorumHash) appears in deletedQuorums +// - For each entry in newQuorums: insert/replace by (llmqType, quorumHash) +// - Cache the latest quorumsCLSigs for Phase L's ChainLock-cycle +// verifier to reach (it indexes into newQuorums by uint16 position +// within the most-recent diff). +// +// Persistence is via QuorumDb (sister of SMLDb): on every accepted +// mnlistdiff we apply() in-memory, then flush state to LevelDB. On +// startup main_dash loads from QuorumDb via replace_state() before any +// diff arrives. Without persistence, ChainLock verify returns NO_POOL +// after every restart until a cold-start mnlistdiff(zero, tip) refills +// the active set — which never happens in steady state because the +// next diff is incremental. +// +// Thread-safety: all mutation goes through the on_mnlistdiff handler +// which posts to ioc; reads from other threads (Phase L's clsig +// verifier) need a shared_mutex once that lands. For now we assume +// single-threaded ioc access. + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { + +class QuorumManager +{ +public: + // Composite key for active-set lookup. We use a flat vector instead + // of an unordered_map because we expect ~100-200 active quorums on + // mainnet at any time and lookups are infrequent (Phase L's + // ChainLock verifier will dominate read traffic). + struct ActiveKey { + uint8_t llmqType; + uint256 quorumHash; + + bool operator==(const ActiveKey& r) const + { + return llmqType == r.llmqType && quorumHash == r.quorumHash; + } + }; + + struct Entry { + ActiveKey key; + vendor::CFinalCommitment commitment; + // Phase C-TEMPLATE step 4 prep: the block height where this + // quorum's qfcommit tx was mined. Populated by the + // [QC-MINED] scanner in main_dash on_full_block when the tx + // is observed. 0 = unknown (mnlistdiff added the quorum but + // we haven't yet seen the corresponding qfcommit tx — happens + // for quorums added before our header checkpoint, or while + // bootstrap is still draining). + // + // dashcore's GetMinedCommitmentsUntilBlock orders quorums by + // mining height (newest first per llmqType), which feeds + // CalcCbTxMerkleRootQuorums. Without this field we can't + // mirror that ordering in step 4c's compute_merkle_root_quorums. + // + // NOT YET PERSISTED. QuorumDb still serializes only the + // commitment; on restart, mining_height resets to 0 and + // repopulates as we observe blocks. Persistence lands in the + // same commit as compute_merkle_root_quorums (when it + // actually starts to matter). + uint32_t mining_height{0}; + }; + + // Look up an entry by (llmqType, quorumHash) and return a mutable + // pointer for in-place state updates (used by the qfcommit + // scanner to set mining_height when a type-6 tx is observed). + // Returns nullptr if not in active set. + Entry* find_mutable(uint8_t llmqType, const uint256& quorumHash) + { + for (auto& e : m_active) { + if (e.key.llmqType == llmqType + && e.key.quorumHash == quorumHash) { + return &e; + } + } + return nullptr; + } + + // Apply a parsed quorum tail. Mutates the active set in place. + // Returns the count of inserts/replaces and deletes for logging. + struct ApplyResult { + size_t added_or_updated{0}; + size_t deleted{0}; + size_t cl_sigs_cached{0}; + size_t active_after{0}; + }; + + ApplyResult apply(const vendor::QuorumTail& tail) + { + ApplyResult r; + + if (!tail.deletedQuorums.empty()) { + auto before = m_active.size(); + m_active.erase( + std::remove_if(m_active.begin(), m_active.end(), + [&](const Entry& e) { + for (const auto& [t, h] : tail.deletedQuorums) { + if (e.key.llmqType == t + && e.key.quorumHash == h) { + return true; + } + } + return false; + }), + m_active.end()); + r.deleted = before - m_active.size(); + } + + for (const auto& nq : tail.newQuorums) { + ActiveKey k{nq.llmqType, nq.quorumHash}; + auto it = std::find_if(m_active.begin(), m_active.end(), + [&](const Entry& e) { return e.key == k; }); + if (it != m_active.end()) { + it->commitment = nq; + } else { + m_active.push_back(Entry{k, nq}); + } + ++r.added_or_updated; + } + + // Replace cached CL sigs each diff (the previous diff's sigs + // are now stale — they refer to that diff's newQuorums-order + // indices, not ours). + m_latest_cl_sigs = tail.quorumsCLSigs; + r.cl_sigs_cached = m_latest_cl_sigs.size(); + r.active_after = m_active.size(); + return r; + } + + // Look up a commitment by (llmqType, quorumHash). Used by Phase L's + // ChainLock verifier: given a clsig blob and the quorum that should + // have signed it, fetch the quorum public key for verification. + std::optional + find(uint8_t llmqType, const uint256& quorumHash) const + { + for (const auto& e : m_active) { + if (e.key.llmqType == llmqType + && e.key.quorumHash == quorumHash) { + return e.commitment; + } + } + return std::nullopt; + } + + size_t active_count() const { return m_active.size(); } + + // Expose active entries by const reference for iteration. Used by + // chainlock_verify.hpp to walk the LLMQ_400_60 candidates when + // selecting the signing quorum. + const std::vector& active_entries() const { return m_active; } + + // Per-type counts for logging / dashboard. + std::unordered_map active_by_type() const + { + std::unordered_map out; + for (const auto& e : m_active) ++out[e.key.llmqType]; + return out; + } + + void clear() + { + m_active.clear(); + m_latest_cl_sigs.clear(); + } + + // Latest cached quorumsCLSigs from the most recent diff. Each entry + // is (96-byte recovered BLS sig, vector indices into the + // most-recent diff's newQuorums). Persistence flushes this so a + // restart between two mnlistdiffs doesn't lose the cycle map Phase L + // reaches into. + using CLSig = std::pair< + std::array, + std::vector>; + + const std::vector& latest_cl_sigs() const + { + return m_latest_cl_sigs; + } + + // Bulk-replace state — used by QuorumDb::load_into() to warm the + // active set + latest CL sigs from disk before the first incremental + // diff arrives. On post-restart steady state this avoids needing a + // full cold-start mnlistdiff(zero, tip). + void replace_state(std::vector&& active, + std::vector&& cl_sigs) + { + m_active = std::move(active); + m_latest_cl_sigs = std::move(cl_sigs); + } + +private: + std::vector m_active; + + // Most-recent diff's quorumsCLSigs — kept verbatim for Phase L's + // cycle-shuffle verifier. Indices reference m_active positions of + // the entries inserted from THIS diff; on the next diff they go + // stale and we replace them. + std::vector m_latest_cl_sigs; +}; + +} // namespace coin +} // namespace dash diff --git a/src/impl/dash/coin/vendor/llmq_commitment.hpp b/src/impl/dash/coin/vendor/llmq_commitment.hpp new file mode 100644 index 000000000..f8be5500c --- /dev/null +++ b/src/impl/dash/coin/vendor/llmq_commitment.hpp @@ -0,0 +1,216 @@ +#pragma once + +// Vendored from dashcore/src/llmq/commitment.h @ develop cfad414 +// (Dash Core v23.1-dev). Phase C-QUO step 1. +// +// Adaptations from upstream: +// +// 1. nVersion stored as raw uint16_t — pack.hpp doesn't support enum- +// class serialization with the is_serializable_enum specialization +// machinery dashcore uses. Named constants kept as constexpr. +// +// 2. LLMQType (Consensus::LLMQType) is uint8_t under the hood. We +// store as raw uint8_t and define the named-quorum constants as +// readable constexpr values. +// +// 3. CBLSPublicKey replaced by 48-byte std::array. CBLSSignature +// replaced by 96-byte std::array. We store wire bytes opaquely; +// Phase L picks a real BLS lib and adds verification. +// +// 4. CBLSPublicKeyVersionWrapper / CBLSSignatureVersionWrapper just +// pick legacy-vs-basic curve scheme — at the wire layer both +// variants emit the same number of bytes (48 / 96), so we drop +// the wrappers and serialize raw. The legacy/basic flag matters +// only at point-decompression time, which we don't do. +// +// 5. SERIALIZE_METHODS body re-expressed in pack.hpp form (1-arg +// macro, formatter-type discrimination). DYNBITSET is implemented +// below as DynBitSetFormat — wire-identical to dashcore's +// DynamicBitSetFormatter (CompactSize(nBits) + ceil(nBits/8) +// bytes, LSB-first within each byte, with a trailing-pad-zero +// check on read for malleation resistance). +// +// 6. Validation methods (Verify, VerifySignatureAsync, VerifyNull, +// VerifySizes) are NOT vendored — they require BLS verification, +// LLMQParams from chain context, and CCheckQueueControl. Phase L +// will add a minimal verifier. + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { +namespace vendor { + +// ── DYNBITSET formatter ──────────────────────────────────────────────────── +// +// Wire format: +// CompactSize(nBits) +// ceil(nBits/8) bytes packed LSB-first within each byte: +// byte[i] bit (1 << j) holds vec[i*8 + j] +// Read MUST verify trailing pad bits in the last byte are zero +// (dashcore serialize.h:447-452 — protects against malleation). +struct DynBitSetFormat +{ + template + static void Write(Stream& s, const std::vector& vec) + { + WriteCompactSize(s, vec.size()); + std::vector bytes((vec.size() + 7) / 8, 0); + for (size_t p = 0; p < vec.size(); ++p) { + if (vec[p]) bytes[p / 8] |= static_cast(1u << (p % 8)); + } + if (!bytes.empty()) { + s.write(std::as_bytes(std::span{bytes})); + } + } + + template + static void Read(Stream& s, std::vector& vec) + { + size_t n = ReadCompactSize(s); + vec.assign(n, false); + size_t nbytes = (n + 7) / 8; + std::vector bytes(nbytes, 0); + if (nbytes > 0) { + s.read(std::as_writable_bytes(std::span{bytes})); + } + for (size_t p = 0; p < n; ++p) { + vec[p] = (bytes[p / 8] & static_cast(1u << (p % 8))) != 0; + } + if (nbytes * 8 != n) { + // Validate trailing pad bits are zero (matches upstream). + size_t rem = nbytes * 8 - n; + uint8_t mask = static_cast(~(0xffu >> rem)); + if (bytes.back() & mask) { + throw std::ios_base::failure( + "DynBitSet: out-of-range bits set"); + } + } + } +}; + +// ── CFinalCommitment ────────────────────────────────────────────────────── + +struct CFinalCommitment +{ + static constexpr uint16_t LEGACY_BLS_NON_INDEXED_QUORUM_VERSION = 1; + static constexpr uint16_t LEGACY_BLS_INDEXED_QUORUM_VERSION = 2; + static constexpr uint16_t BASIC_BLS_NON_INDEXED_QUORUM_VERSION = 3; + static constexpr uint16_t BASIC_BLS_INDEXED_QUORUM_VERSION = 4; + + static constexpr size_t BLS_PUBKEY_SIZE = 48; + static constexpr size_t BLS_SIG_SIZE = 96; + + // LLMQType named values for production quorums (dashcore params.h:14). + // 0xff = LLMQ_NONE. + static constexpr uint8_t LLMQ_NONE = 0xff; + static constexpr uint8_t LLMQ_50_60 = 1; + static constexpr uint8_t LLMQ_400_60 = 2; + static constexpr uint8_t LLMQ_400_85 = 3; + static constexpr uint8_t LLMQ_100_67 = 4; + static constexpr uint8_t LLMQ_60_75 = 5; + static constexpr uint8_t LLMQ_25_67 = 6; + + uint16_t nVersion{LEGACY_BLS_NON_INDEXED_QUORUM_VERSION}; + uint8_t llmqType{LLMQ_NONE}; + uint256 quorumHash; + int16_t quorumIndex{0}; // only if INDEXED variant + std::vector signers; // DYNBITSET + std::vector validMembers; // DYNBITSET + std::array quorumPublicKey{}; + uint256 quorumVvecHash; + std::array quorumSig{}; + std::array membersSig{}; + + bool is_indexed_version() const + { + return nVersion == LEGACY_BLS_INDEXED_QUORUM_VERSION + || nVersion == BASIC_BLS_INDEXED_QUORUM_VERSION; + } + + C2POOL_SERIALIZE_METHODS(CFinalCommitment) + { + READWRITE(obj.nVersion, obj.llmqType, obj.quorumHash); + if (obj.nVersion == LEGACY_BLS_INDEXED_QUORUM_VERSION + || obj.nVersion == BASIC_BLS_INDEXED_QUORUM_VERSION) { + READWRITE(obj.quorumIndex); + } + READWRITE(Using(obj.signers), + Using(obj.validMembers), + Using>(obj.quorumPublicKey), + obj.quorumVvecHash, + Using>(obj.quorumSig), + Using>(obj.membersSig)); + } + + int CountSigners() const + { + return static_cast( + std::count(signers.begin(), signers.end(), true)); + } + + int CountValidMembers() const + { + return static_cast( + std::count(validMembers.begin(), validMembers.end(), true)); + } +}; + +// ── CFinalCommitmentTxPayload ───────────────────────────────────────────── +// +// The wrapping payload for type-6 (TRANSACTION_QUORUM_COMMITMENT) +// special txs. Each accepted Dash block can carry one or more of +// these — they're how a successfully-finalized DKG cycle's commitment +// gets committed to the chain. +// +// We parse them in on_full_block to track per-quorum mining heights +// (Phase C-TEMPLATE step 3 prep): merkleRootQuorums computation needs +// to order quorums by mining height (newest-first per llmqType), and +// mnlistdiff alone doesn't carry that info — only the chain does. +// +// Vendored from dashcore/src/llmq/commitment.h:147-165 @ cfad414. +struct CFinalCommitmentTxPayload +{ + static constexpr uint16_t SPECIALTX_TYPE = 6; + static constexpr uint16_t CURRENT_VERSION = 1; + + uint16_t nVersion{CURRENT_VERSION}; + uint32_t nHeight{0}; + CFinalCommitment commitment; + + C2POOL_SERIALIZE_METHODS(CFinalCommitmentTxPayload) + { + READWRITE(obj.nVersion, obj.nHeight, obj.commitment); + } +}; + +inline bool parse_qfcommit_payload(const std::vector& bytes, + CFinalCommitmentTxPayload& out) +{ + if (bytes.empty()) return false; + try { + ::PackStream s(bytes); + s >> out; + // Match the strict-tail policy from parse_protx_payload — an + // unconsumed remainder is a wire-format drift smell. + return s.cursor_size() == 0; + } catch (const std::exception&) { + return false; + } +} + +} // namespace vendor +} // namespace coin +} // namespace dash diff --git a/src/impl/dash/coin/vendor/quorum_tail.hpp b/src/impl/dash/coin/vendor/quorum_tail.hpp new file mode 100644 index 000000000..f07da78e8 --- /dev/null +++ b/src/impl/dash/coin/vendor/quorum_tail.hpp @@ -0,0 +1,138 @@ +#pragma once + +// Phase C-QUO step 2: structured parser for the mnlistdiff "quorum tail" +// (deletedQuorums + newQuorums + quorumsCLSigs). +// +// Design constraint: smldiff.hpp's CSimplifiedMNListDiff keeps the tail +// as an opaque std::vector. That choice is preserved — SML +// sync (the Phase C-SML acceptance gate) MUST NOT fail if quorum-tail +// parsing breaks on an unknown CFinalCommitment nVersion or wire-format +// drift. +// +// This header layers a fail-safe structured parser on TOP of the opaque +// tail. The on_mnlistdiff callback applies the SML first, then calls +// parse_quorum_tail() — on success the structured fields are returned; +// on any parse failure we log a warning and skip quorum tracking for +// that diff. SML sync continues unaffected. +// +// Wire format of the tail (dashcore evo/smldiff.h:65-80, gated on +// MNLISTDIFF_CHAINLOCKS_PROTO_VERSION which is 70230 == our advertised +// proto): +// +// vector> deletedQuorums +// vector newQuorums +// map> quorumsCLSigs + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace dash { +namespace coin { +namespace vendor { + +struct QuorumTail +{ + std::vector> deletedQuorums; + std::vector newQuorums; + + // Pre-Phase-L we don't index this map by signature value; the + // entries arrive in dashcore-canonical order and we just keep them + // for forwarding into the manager (Phase L will deserialize the + // shuffling ChainLocks here). + std::vector, + std::vector>> quorumsCLSigs; +}; + +// Decode the opaque tail. Returns true on bit-exact success (no +// trailing garbage). Returns false on any deserialization failure or +// trailing bytes — caller should log the failure and continue without +// the quorum data for this diff. The `out` parameter is left in an +// indeterminate state on failure. +inline bool parse_quorum_tail(const std::vector& bytes, + QuorumTail& out) +{ + if (bytes.empty()) { + // Nothing to parse — valid pre-MNLISTDIFF_CHAINLOCKS_PROTO peer + // would emit zero tail; we never see that against current proto + // 70230 but the empty-input case is still a clean success. + out = QuorumTail{}; + return true; + } + + try { + ::PackStream s(bytes); + + // deletedQuorums + { + uint64_t n = ReadCompactSize(s); + out.deletedQuorums.clear(); + out.deletedQuorums.reserve(n); + for (uint64_t i = 0; i < n; ++i) { + uint8_t llmqType = 0; + uint256 hash; + s >> llmqType; + s >> hash; + out.deletedQuorums.emplace_back(llmqType, hash); + } + } + + // newQuorums + { + uint64_t n = ReadCompactSize(s); + out.newQuorums.clear(); + out.newQuorums.reserve(n); + for (uint64_t i = 0; i < n; ++i) { + CFinalCommitment c; + s >> c; + out.newQuorums.push_back(std::move(c)); + } + } + + // quorumsCLSigs: map> + { + uint64_t n = ReadCompactSize(s); + out.quorumsCLSigs.clear(); + out.quorumsCLSigs.reserve(n); + for (uint64_t i = 0; i < n; ++i) { + std::array sig{}; + s.read(std::as_writable_bytes(std::span{sig})); + uint64_t mset = ReadCompactSize(s); + std::vector indices; + indices.reserve(mset); + for (uint64_t j = 0; j < mset; ++j) { + uint16_t idx = 0; + s >> idx; + indices.push_back(idx); + } + out.quorumsCLSigs.emplace_back(sig, std::move(indices)); + } + } + + if (s.cursor_size() != 0) { + LOG_WARNING << "[QUO] parse_quorum_tail: " + << s.cursor_size() + << " trailing bytes after structured parse " + "— possible wire-format drift"; + return false; + } + return true; + } catch (const std::exception& e) { + LOG_WARNING << "[QUO] parse_quorum_tail failed: " << e.what() + << " bytes=" << bytes.size(); + return false; + } +} + +} // namespace vendor +} // namespace coin +} // namespace dash diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index c56efa65f..e78d8845d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -297,6 +297,19 @@ if (BUILD_TESTING AND GTest_FOUND) target_link_libraries(test_dash_simplifiedmns PRIVATE c2pool_payout c2pool_hashrate c2pool_merged_mining) # OBJECT-lib SCC direct-naming (#22/#39) gtest_add_tests(test_dash_simplifiedmns "" AUTO) + # Header-only LLMQ quorum-tail verification leaf over core/uint256 + + # core/pack; no ltc/pool SCC dependency. Sits between the + # SimplifiedMNList leaf (#309) and embedded_gbt main (S7). + add_executable(test_dash_quorum test_dash_quorum.cpp) + target_link_libraries(test_dash_quorum PRIVATE + GTest::gtest_main GTest::gtest + dash_x11 core + nlohmann_json::nlohmann_json + ${Boost_LIBRARIES} + ) + 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) + 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.cpp b/test/test_dash_quorum.cpp new file mode 100644 index 000000000..543f86214 --- /dev/null +++ b/test/test_dash_quorum.cpp @@ -0,0 +1,418 @@ +/// Phase C-QUO — Dash LLMQ quorum-tail verification leaf unit tests +/// +/// Exercises the vendored CFinalCommitment / QuorumTail parser and the +/// in-memory QuorumManager active-set tracker: +/// +/// - src/impl/dash/coin/vendor/llmq_commitment.hpp +/// - src/impl/dash/coin/vendor/quorum_tail.hpp +/// - src/impl/dash/coin/quorum_manager.hpp +/// +/// This is the quorum/LLMQ verification leaf that sits between the +/// SimplifiedMNList leaf (#309) and the embedded_gbt main: it pins the +/// wire semantics of the mnlistdiff "quorum tail" (deletedQuorums + +/// newQuorums + quorumsCLSigs) that Phase L's ChainLock verifier and +/// Phase C-TEMPLATE's merkleRootQuorums computation both reach into. +/// +/// KATs pinned here: +/// - CFinalCommitment round-trips byte-for-byte against a hand-built +/// wire vector (non-indexed v1) — nVersion(LE16), llmqType, quorumHash, +/// DYNBITSET signers/validMembers, raw 48/32/96/96 trailers. +/// - The INDEXED version gate (v2/v4) emits quorumIndex(int16) right +/// after quorumHash; non-indexed (v1/v3) omits it — length differs +/// by exactly 2 and quorumIndex round-trips. +/// - DYNBITSET is LSB-first within each byte and REJECTS out-of-range +/// trailing pad bits (dashcore malleation guard) on read. +/// - parse_quorum_tail decodes deletedQuorums + newQuorums + +/// quorumsCLSigs bit-exactly, treats empty input as clean success, +/// and rejects trailing garbage (wire-drift smell). +/// - parse_qfcommit_payload (type-6 special tx) round-trips and +/// enforces the strict-tail policy. +/// - QuorumManager.apply() insert/replace/delete semantics, find / +/// find_mutable / active_by_type / replace_state / CL-sig caching. +/// +/// SCOPE NOTE (honest): these are structural + bit-exact wire KATs that +/// are fully self-contained. BLS signature verification and the +/// end-to-end "active set matches a live mnlistdiff from a Dash node" +/// cross-check are explicitly NOT vendored yet (Phase L) and NOT claimed +/// here. + +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +using dash::coin::vendor::CFinalCommitment; +using dash::coin::vendor::CFinalCommitmentTxPayload; +using dash::coin::vendor::QuorumTail; +using dash::coin::vendor::parse_quorum_tail; +using dash::coin::vendor::parse_qfcommit_payload; +using dash::coin::QuorumManager; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +// uint256 with a single nonzero byte at memory index 0. +static uint256 raw256_b0(uint8_t val) { + uint256 h; + std::array p{}; + p[0] = val; + std::memcpy(h.data(), p.data(), 32); + return h; +} + +template +static std::vector ser(const T& obj) { + auto ps = ::pack(obj); + std::vector out(ps.size()); + if (!out.empty()) + std::memcpy(out.data(), ps.data(), ps.size()); + return out; +} + +template +static T deser(const std::vector& bytes) { + ::PackStream s(bytes); + T out; + s >> out; + return out; +} + +static void push(std::vector& v, std::initializer_list bs) { + for (auto b : bs) v.push_back(b); +} +static void push_n(std::vector& v, uint8_t first, size_t n) { + if (n == 0) return; + v.push_back(first); + for (size_t i = 1; i < n; ++i) v.push_back(0); +} + +static void expect_commitment_eq(const CFinalCommitment& a, + const CFinalCommitment& b) { + EXPECT_EQ(a.nVersion, b.nVersion); + EXPECT_EQ(a.llmqType, b.llmqType); + EXPECT_EQ(a.quorumHash, b.quorumHash); + EXPECT_EQ(a.quorumIndex, b.quorumIndex); + EXPECT_EQ(a.signers, b.signers); + EXPECT_EQ(a.validMembers, b.validMembers); + EXPECT_EQ(a.quorumPublicKey, b.quorumPublicKey); + EXPECT_EQ(a.quorumVvecHash, b.quorumVvecHash); + EXPECT_EQ(a.quorumSig, b.quorumSig); + EXPECT_EQ(a.membersSig, b.membersSig); +} + +// A deterministic commitment with fully-populated raw trailers. +static CFinalCommitment make_commitment(uint16_t nVersion, uint8_t llmqType, + uint8_t quorumHashByte0) { + CFinalCommitment c; + c.nVersion = nVersion; + c.llmqType = llmqType; + c.quorumHash = raw256_b0(quorumHashByte0); + c.quorumIndex = c.is_indexed_version() ? static_cast(0x0102) : 0; + c.signers = {true, false, true}; // 3 bits → 0x05 + c.validMembers = {true, true, false}; // 3 bits → 0x03 + c.quorumPublicKey.fill(0); c.quorumPublicKey[0] = 0xBB; + c.quorumVvecHash = raw256_b0(0xCC); + c.quorumSig.fill(0); c.quorumSig[0] = 0xDD; + c.membersSig.fill(0); c.membersSig[0] = 0xEE; + return c; +} + +// ─── KAT 1: CFinalCommitment byte-exact wire (non-indexed v1) ─────────────── + +TEST(DashQuorum, FinalCommitmentByteExactNonIndexed) { + auto c = make_commitment( + CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION, + CFinalCommitment::LLMQ_50_60, 0xAA); + + std::vector expected; + push(expected, {0x01, 0x00}); // nVersion = 1 (LE16) + push(expected, {0x01}); // llmqType = 1 + push_n(expected, 0xAA, 32); // quorumHash + // NO quorumIndex (non-indexed) + push(expected, {0x03, 0x05}); // signers: CompactSize(3), LSB byte 0x05 + push(expected, {0x03, 0x03}); // validMembers: CompactSize(3), 0x03 + push_n(expected, 0xBB, 48); // quorumPublicKey + push_n(expected, 0xCC, 32); // quorumVvecHash + push_n(expected, 0xDD, 96); // quorumSig + push_n(expected, 0xEE, 96); // membersSig + + EXPECT_EQ(ser(c), expected); + + auto back = deser(expected); + expect_commitment_eq(back, c); +} + +// ─── KAT 2: indexed version gate emits quorumIndex(int16) ─────────────────── + +TEST(DashQuorum, IndexedVersionGateAddsTwoBytes) { + auto base = make_commitment( + CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION, + CFinalCommitment::LLMQ_400_60, 0x11); + auto indexed = make_commitment( + CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION, + CFinalCommitment::LLMQ_400_60, 0x11); + + auto base_wire = ser(base); + auto indexed_wire = ser(indexed); + + // Indexed payload is exactly 2 bytes longer (the int16 quorumIndex). + EXPECT_EQ(indexed_wire.size(), base_wire.size() + 2); + + // quorumIndex sits immediately after quorumHash: + // nVersion(2) + llmqType(1) + quorumHash(32) = offset 35, LE16 0x0102. + EXPECT_EQ(indexed_wire[35], 0x02); + EXPECT_EQ(indexed_wire[36], 0x01); + + auto back = deser(indexed_wire); + EXPECT_TRUE(back.is_indexed_version()); + EXPECT_EQ(back.quorumIndex, 0x0102); + expect_commitment_eq(back, indexed); +} + +// ─── KAT 3: DYNBITSET LSB-first + 9-bit two-byte packing ──────────────────── + +TEST(DashQuorum, DynBitSetLsbFirstNineBits) { + auto c = make_commitment( + CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION, + CFinalCommitment::LLMQ_50_60, 0x01); + // 9 bits: positions 0 and 2 set in byte 0, position 8 set in byte 1. + c.signers = {true, false, true, false, false, false, false, false, true}; + auto wire = ser(c); + + // signers section starts after nVersion(2)+llmqType(1)+quorumHash(32) = 35. + EXPECT_EQ(wire[35], 0x09); // CompactSize(9) + EXPECT_EQ(wire[36], 0x05); // byte 0: bits 0,2 + EXPECT_EQ(wire[37], 0x01); // byte 1: bit 8 + + auto back = deser(wire); + EXPECT_EQ(back.signers, c.signers); + EXPECT_EQ(back.CountSigners(), 3); +} + +// ─── KAT 4: DYNBITSET rejects out-of-range trailing pad bits ──────────────── + +TEST(DashQuorum, DynBitSetRejectsMalleatedPadBits) { + auto c = make_commitment( + CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION, + CFinalCommitment::LLMQ_50_60, 0x01); + c.signers = {true, false, true}; // 3 bits, byte = 0x05; bits 3..7 are pad + auto wire = ser(c); + ASSERT_EQ(wire[35], 0x03); + ASSERT_EQ(wire[36], 0x05); + + // Flip an out-of-range pad bit (bit 3) → must be rejected on read. + wire[36] = 0x0D; // 0x05 | (1<<3) + EXPECT_THROW(deser(wire), std::exception); +} + +// ─── KAT 5: parse_quorum_tail decodes all three sections ──────────────────── + +static std::vector build_tail_bytes( + const std::vector>& deleted, + const std::vector& newq, + const std::vector, std::vector>>& clsigs) { + std::vector v; + // deletedQuorums + v.push_back(static_cast(deleted.size())); // small CompactSize + for (auto& [t, h] : deleted) { + v.push_back(t); + const auto* p = reinterpret_cast(h.data()); + v.insert(v.end(), p, p + 32); + } + // newQuorums + v.push_back(static_cast(newq.size())); + for (auto& c : newq) { + auto w = ser(c); + v.insert(v.end(), w.begin(), w.end()); + } + // quorumsCLSigs + v.push_back(static_cast(clsigs.size())); + for (auto& [sig, idxs] : clsigs) { + v.insert(v.end(), sig.begin(), sig.end()); + v.push_back(static_cast(idxs.size())); + for (auto idx : idxs) { + v.push_back(static_cast(idx & 0xff)); + v.push_back(static_cast((idx >> 8) & 0xff)); + } + } + return v; +} + +TEST(DashQuorum, ParseQuorumTailAllSections) { + auto c0 = make_commitment( + CFinalCommitment::LEGACY_BLS_NON_INDEXED_QUORUM_VERSION, + CFinalCommitment::LLMQ_50_60, 0x21); + auto c1 = make_commitment( + CFinalCommitment::BASIC_BLS_INDEXED_QUORUM_VERSION, + CFinalCommitment::LLMQ_400_60, 0x22); + + std::array sig{}; sig[0] = 0x7F; + auto bytes = build_tail_bytes( + {{CFinalCommitment::LLMQ_50_60, raw256_b0(0x33)}}, + {c0, c1}, + {{sig, {0, 1, 2}}}); + + QuorumTail tail; + ASSERT_TRUE(parse_quorum_tail(bytes, tail)); + + ASSERT_EQ(tail.deletedQuorums.size(), 1u); + EXPECT_EQ(tail.deletedQuorums[0].first, CFinalCommitment::LLMQ_50_60); + EXPECT_EQ(tail.deletedQuorums[0].second, raw256_b0(0x33)); + + ASSERT_EQ(tail.newQuorums.size(), 2u); + expect_commitment_eq(tail.newQuorums[0], c0); + expect_commitment_eq(tail.newQuorums[1], c1); + + ASSERT_EQ(tail.quorumsCLSigs.size(), 1u); + EXPECT_EQ(tail.quorumsCLSigs[0].first, sig); + EXPECT_EQ(tail.quorumsCLSigs[0].second, (std::vector{0, 1, 2})); +} + +TEST(DashQuorum, ParseQuorumTailEmptyIsCleanSuccess) { + QuorumTail tail; + EXPECT_TRUE(parse_quorum_tail({}, tail)); + EXPECT_TRUE(tail.deletedQuorums.empty()); + EXPECT_TRUE(tail.newQuorums.empty()); + EXPECT_TRUE(tail.quorumsCLSigs.empty()); +} + +TEST(DashQuorum, ParseQuorumTailRejectsTrailingGarbage) { + auto bytes = build_tail_bytes({}, {}, {}); // 3 zero CompactSizes + bytes.push_back(0xFF); // wire-drift smell + QuorumTail tail; + EXPECT_FALSE(parse_quorum_tail(bytes, tail)); +} + +// ─── KAT 6: parse_qfcommit_payload (type-6 special tx) ────────────────────── + +TEST(DashQuorum, ParseQfcommitPayloadRoundTripAndStrictTail) { + CFinalCommitmentTxPayload pl; + pl.nVersion = CFinalCommitmentTxPayload::CURRENT_VERSION; + pl.nHeight = 0x00BEEF12; + pl.commitment = make_commitment( + CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION, + CFinalCommitment::LLMQ_100_67, 0x44); + + auto wire = ser(pl); + CFinalCommitmentTxPayload out; + ASSERT_TRUE(parse_qfcommit_payload(wire, out)); + EXPECT_EQ(out.nVersion, pl.nVersion); + EXPECT_EQ(out.nHeight, pl.nHeight); + expect_commitment_eq(out.commitment, pl.commitment); + + // Strict-tail policy: any trailing byte is rejected. + wire.push_back(0x00); + CFinalCommitmentTxPayload out2; + EXPECT_FALSE(parse_qfcommit_payload(wire, out2)); + + // Empty input is rejected (not a valid payload). + CFinalCommitmentTxPayload out3; + EXPECT_FALSE(parse_qfcommit_payload({}, out3)); +} + +// ─── KAT 7: QuorumManager apply / find / delete / replace ─────────────────── + +static CFinalCommitment quorum_at(uint8_t llmqType, uint8_t hashByte) { + auto c = make_commitment( + CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION, + llmqType, hashByte); + return c; +} + +TEST(DashQuorum, ManagerApplyInsertReplaceDelete) { + QuorumManager mgr; + EXPECT_EQ(mgr.active_count(), 0u); + + QuorumTail t1; + t1.newQuorums = {quorum_at(CFinalCommitment::LLMQ_50_60, 0x01), + quorum_at(CFinalCommitment::LLMQ_400_60, 0x02)}; + std::array sig{}; sig[0] = 0x09; + t1.quorumsCLSigs = {{sig, {0, 1}}}; + + auto r1 = mgr.apply(t1); + EXPECT_EQ(r1.added_or_updated, 2u); + EXPECT_EQ(r1.deleted, 0u); + EXPECT_EQ(r1.cl_sigs_cached, 1u); + EXPECT_EQ(r1.active_after, 2u); + EXPECT_EQ(mgr.active_count(), 2u); + + // find existing / missing + auto found = mgr.find(CFinalCommitment::LLMQ_50_60, raw256_b0(0x01)); + ASSERT_TRUE(found.has_value()); + EXPECT_EQ(found->llmqType, CFinalCommitment::LLMQ_50_60); + EXPECT_FALSE(mgr.find(CFinalCommitment::LLMQ_50_60, raw256_b0(0x09)).has_value()); + + // replace one (same key) + delete the other in one diff + QuorumTail t2; + auto replacement = quorum_at(CFinalCommitment::LLMQ_50_60, 0x01); + replacement.quorumSig[0] = 0x77; // mutated payload, same key + t2.newQuorums = {replacement}; + t2.deletedQuorums = {{CFinalCommitment::LLMQ_400_60, raw256_b0(0x02)}}; + + auto r2 = mgr.apply(t2); + EXPECT_EQ(r2.added_or_updated, 1u); + EXPECT_EQ(r2.deleted, 1u); + EXPECT_EQ(r2.active_after, 1u); + EXPECT_EQ(mgr.active_count(), 1u); + + auto reborn = mgr.find(CFinalCommitment::LLMQ_50_60, raw256_b0(0x01)); + ASSERT_TRUE(reborn.has_value()); + EXPECT_EQ(reborn->quorumSig[0], 0x77); // replaced in place + + // Latest CL sigs replaced (t2 carried none). + EXPECT_TRUE(mgr.latest_cl_sigs().empty()); +} + +TEST(DashQuorum, ManagerFindMutableAndActiveByType) { + QuorumManager mgr; + QuorumTail t; + t.newQuorums = {quorum_at(CFinalCommitment::LLMQ_50_60, 0x01), + quorum_at(CFinalCommitment::LLMQ_50_60, 0x02), + quorum_at(CFinalCommitment::LLMQ_400_60, 0x03)}; + mgr.apply(t); + + auto by_type = mgr.active_by_type(); + EXPECT_EQ(by_type[CFinalCommitment::LLMQ_50_60], 2u); + EXPECT_EQ(by_type[CFinalCommitment::LLMQ_400_60], 1u); + + // find_mutable populates mining_height in place. + auto* e = mgr.find_mutable(CFinalCommitment::LLMQ_400_60, raw256_b0(0x03)); + ASSERT_NE(e, nullptr); + EXPECT_EQ(e->mining_height, 0u); + e->mining_height = 2128896; + EXPECT_EQ(mgr.find_mutable(CFinalCommitment::LLMQ_400_60, raw256_b0(0x03))->mining_height, + 2128896u); + EXPECT_EQ(mgr.find_mutable(CFinalCommitment::LLMQ_400_60, raw256_b0(0x99)), nullptr); +} + +TEST(DashQuorum, ManagerReplaceStateWarmsActiveSet) { + QuorumManager mgr; + std::vector warm; + QuorumManager::Entry e; + e.key = {CFinalCommitment::LLMQ_100_67, raw256_b0(0x05)}; + e.commitment = quorum_at(CFinalCommitment::LLMQ_100_67, 0x05); + e.mining_height = 42; + warm.push_back(e); + + std::array sig{}; sig[0] = 0xAB; + std::vector sigs{{sig, {7}}}; + + mgr.replace_state(std::move(warm), std::move(sigs)); + EXPECT_EQ(mgr.active_count(), 1u); + ASSERT_TRUE(mgr.find(CFinalCommitment::LLMQ_100_67, raw256_b0(0x05)).has_value()); + ASSERT_EQ(mgr.latest_cl_sigs().size(), 1u); + EXPECT_EQ(mgr.latest_cl_sigs()[0].second, (std::vector{7})); + + mgr.clear(); + EXPECT_EQ(mgr.active_count(), 0u); + EXPECT_TRUE(mgr.latest_cl_sigs().empty()); +}