From 11897c9c2dadef588411e27c0672a9c12c2d2c68 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 02:32:22 -0600 Subject: [PATCH 01/12] feat(llmq): add trustless quorum proof chain generation and verification This implements a trustless mechanism for verifying quorum public keys without requiring the full blockchain history. A verifier with a known checkpoint (trusted chainlock quorum public keys) can verify any subsequent quorum's authenticity through a chain of cryptographic proofs. Key components: - Chainlock indexing: Store chainlocks from cbtx during block processing - Merkle proof generation: Build proofs linking commitments to blocks - Iterative proof chain building: Handle bridging scenarios where intermediate quorums must be proven before the target - Proof verification: Validate the chain starting from checkpoint New RPCs: - getchainlockbyheight: Retrieve indexed chainlock at a specific height - getquorumproofchain: Generate proof chain from checkpoint to target - verifyquorumproofchain: Verify a proof chain and extract public key Security features: - DoS protection with MAX_PROOF_CHAIN_LENGTH (50 quorums max) - Cycle detection to prevent infinite loops - Header chain continuity verification - BLS signature verification against known quorum keys Co-Authored-By: Claude Opus 4.5 --- src/Makefile.am | 2 + src/Makefile.test.include | 1 + src/evo/chainhelper.cpp | 5 +- src/evo/chainhelper.h | 4 +- src/evo/specialtxman.cpp | 26 + src/evo/specialtxman.h | 8 +- src/llmq/context.cpp | 2 + src/llmq/context.h | 2 + src/llmq/quorumproofs.cpp | 695 ++++++++++++++++++ src/llmq/quorumproofs.h | 226 ++++++ src/node/chainstate.cpp | 2 +- src/rpc/quorums.cpp | 228 ++++++ src/test/quorum_proofs_tests.cpp | 572 ++++++++++++++ test/functional/feature_quorum_proof_chain.py | 146 ++++ test/functional/test_runner.py | 1 + 15 files changed, 1914 insertions(+), 6 deletions(-) create mode 100644 src/llmq/quorumproofs.cpp create mode 100644 src/llmq/quorumproofs.h create mode 100644 src/test/quorum_proofs_tests.cpp create mode 100644 test/functional/feature_quorum_proof_chain.py diff --git a/src/Makefile.am b/src/Makefile.am index 1c5fd1936399..2e2056cd350d 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -282,6 +282,7 @@ BITCOIN_CORE_H = \ llmq/ehf_signals.h \ llmq/options.h \ llmq/params.h \ + llmq/quorumproofs.h \ llmq/quorums.h \ llmq/quorumsman.h \ llmq/signhash.h \ @@ -557,6 +558,7 @@ libbitcoin_node_a_SOURCES = \ llmq/ehf_signals.cpp \ llmq/net_signing.cpp \ llmq/options.cpp \ + llmq/quorumproofs.cpp \ llmq/quorums.cpp \ llmq/quorumsman.cpp \ llmq/signhash.cpp \ diff --git a/src/Makefile.test.include b/src/Makefile.test.include index dd6dda7178c3..c0e387fc28e3 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -140,6 +140,7 @@ BITCOIN_TESTS =\ test/llmq_snapshot_tests.cpp \ test/llmq_utils_tests.cpp \ test/logging_tests.cpp \ + test/quorum_proofs_tests.cpp \ test/dbwrapper_tests.cpp \ test/validation_tests.cpp \ test/mempool_tests.cpp \ diff --git a/src/evo/chainhelper.cpp b/src/evo/chainhelper.cpp index 9c11fc4e6a3c..ab09c6b8d97a 100644 --- a/src/evo/chainhelper.cpp +++ b/src/evo/chainhelper.cpp @@ -17,12 +17,13 @@ CChainstateHelper::CChainstateHelper(CCreditPoolManager& cpoolman, CDeterministi llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, const ChainstateManager& chainman, const Consensus::Params& consensus_params, const CMasternodeSync& mn_sync, const CSporkManager& sporkman, - const llmq::CChainLocksHandler& clhandler, const llmq::CQuorumManager& qman) : + const llmq::CChainLocksHandler& clhandler, const llmq::CQuorumManager& qman, + llmq::CQuorumProofManager& quorum_proof_manager) : isman{isman}, clhandler{clhandler}, mn_payments{std::make_unique(dmnman, govman, chainman, consensus_params, mn_sync, sporkman)}, special_tx{std::make_unique(cpoolman, dmnman, mnhfman, qblockman, qsnapman, chainman, - consensus_params, clhandler, qman)} + consensus_params, clhandler, qman, quorum_proof_manager)} {} CChainstateHelper::~CChainstateHelper() = default; diff --git a/src/evo/chainhelper.h b/src/evo/chainhelper.h index 66bee994652f..abe1c93b10df 100644 --- a/src/evo/chainhelper.h +++ b/src/evo/chainhelper.h @@ -26,6 +26,7 @@ class CChainLocksHandler; class CInstantSendManager; class CQuorumBlockProcessor; class CQuorumManager; +class CQuorumProofManager; class CQuorumSnapshotManager; } @@ -44,7 +45,8 @@ class CChainstateHelper llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, const ChainstateManager& chainman, const Consensus::Params& consensus_params, const CMasternodeSync& mn_sync, const CSporkManager& sporkman, - const llmq::CChainLocksHandler& clhandler, const llmq::CQuorumManager& qman); + const llmq::CChainLocksHandler& clhandler, const llmq::CQuorumManager& qman, + llmq::CQuorumProofManager& quorum_proof_manager); ~CChainstateHelper(); /** Passthrough functions to CChainLocksHandler */ diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 3cdb9e7e79d8..0a68652bc45d 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -662,6 +663,23 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(const CBlock& block, const CB return false; } + // Index the chainlock from cbtx for proof generation + // Only index if not just checking AND block is part of the active chain + // This prevents indexing chainlocks from blocks during a reorg + if (!fJustCheck && opt_cbTx->bestCLSignature.IsValid() && + m_chainman.ActiveChain().Contains(pindex)) { + int32_t chainlockedHeight = pindex->nHeight - static_cast(opt_cbTx->bestCLHeightDiff) - 1; + const CBlockIndex* pChainlockedBlock = pindex->GetAncestor(chainlockedHeight); + if (pChainlockedBlock) { + m_quorum_proof_manager.IndexChainlock( + chainlockedHeight, + pChainlockedBlock->GetBlockHash(), + opt_cbTx->bestCLSignature, + pindex->GetBlockHash(), + pindex->nHeight); + } + } + int64_t nTime6_3 = GetTimeMicros(); nTimeCbTxCL += nTime6_3 - nTime6_2; LogPrint(BCLog::BENCHMARK, " - CheckCbTxBestChainlock: %.2fms [%.2fs]\n", @@ -719,6 +737,14 @@ bool CSpecialTxProcessor::UndoSpecialTxsInBlock(const CBlock& block, const CBloc if (!m_qblockman.UndoBlock(block, pindex)) { return false; } + + // Remove chainlock index for this block's cbtx + if (block.vtx.size() > 0 && block.vtx[0]->nType == TRANSACTION_COINBASE) { + if (const auto opt_cbTx = GetTxPayload(*block.vtx[0]); opt_cbTx && opt_cbTx->bestCLSignature.IsValid()) { + int32_t chainlockedHeight = pindex->nHeight - static_cast(opt_cbTx->bestCLHeightDiff) - 1; + m_quorum_proof_manager.RemoveChainlockIndex(chainlockedHeight); + } + } } catch (const std::exception& e) { bls::bls_legacy_scheme.store(bls_legacy_scheme); LogPrintf("CSpecialTxProcessor::%s -- bls_legacy_scheme=%d\n", __func__, bls::bls_legacy_scheme.load()); diff --git a/src/evo/specialtxman.h b/src/evo/specialtxman.h index de293d0dfaba..3f5e0c4b6394 100644 --- a/src/evo/specialtxman.h +++ b/src/evo/specialtxman.h @@ -30,6 +30,7 @@ namespace llmq { class CChainLocksHandler; class CQuorumBlockProcessor; class CQuorumManager; +class CQuorumProofManager; class CQuorumSnapshotManager; } // namespace llmq @@ -47,12 +48,14 @@ class CSpecialTxProcessor const Consensus::Params& m_consensus_params; const llmq::CChainLocksHandler& m_clhandler; const llmq::CQuorumManager& m_qman; + llmq::CQuorumProofManager& m_quorum_proof_manager; public: explicit CSpecialTxProcessor(CCreditPoolManager& cpoolman, CDeterministicMNManager& dmnman, CMNHFManager& mnhfman, llmq::CQuorumBlockProcessor& qblockman, llmq::CQuorumSnapshotManager& qsnapman, const ChainstateManager& chainman, const Consensus::Params& consensus_params, - const llmq::CChainLocksHandler& clhandler, const llmq::CQuorumManager& qman) : + const llmq::CChainLocksHandler& clhandler, const llmq::CQuorumManager& qman, + llmq::CQuorumProofManager& quorum_proof_manager) : m_cpoolman(cpoolman), m_dmnman{dmnman}, m_mnhfman{mnhfman}, @@ -61,7 +64,8 @@ class CSpecialTxProcessor m_chainman(chainman), m_consensus_params{consensus_params}, m_clhandler{clhandler}, - m_qman{qman} + m_qman{qman}, + m_quorum_proof_manager{quorum_proof_manager} { } diff --git a/src/llmq/context.cpp b/src/llmq/context.cpp index d22a8de1156a..983bf6c373fd 100644 --- a/src/llmq/context.cpp +++ b/src/llmq/context.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +23,7 @@ LLMQContext::LLMQContext(CDeterministicMNManager& dmnman, CEvoDB& evo_db, CSpork *qsnapman, bls_threads)}, qman{std::make_unique(*bls_worker, dmnman, evo_db, *quorum_block_processor, *qsnapman, chainman, db_params)}, + quorum_proof_manager{std::make_unique(evo_db, *quorum_block_processor)}, sigman{std::make_unique(*qman, db_params, max_recsigs_age)}, clhandler{std::make_unique(chainman.ActiveChainstate(), *qman, sporkman, mempool, mn_sync)}, isman{std::make_unique(*clhandler, chainman.ActiveChainstate(), *sigman, sporkman, diff --git a/src/llmq/context.h b/src/llmq/context.h index 1e2e84e25a0e..7a7431dd5542 100644 --- a/src/llmq/context.h +++ b/src/llmq/context.h @@ -24,6 +24,7 @@ class CChainLocksHandler; class CInstantSendManager; class CQuorumBlockProcessor; class CQuorumManager; +class CQuorumProofManager; class CQuorumSnapshotManager; class CSigningManager; } // namespace llmq @@ -58,6 +59,7 @@ struct LLMQContext { const std::unique_ptr qsnapman; const std::unique_ptr quorum_block_processor; const std::unique_ptr qman; + const std::unique_ptr quorum_proof_manager; const std::unique_ptr sigman; const std::unique_ptr clhandler; const std::unique_ptr isman; diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp new file mode 100644 index 000000000000..3acb8373a148 --- /dev/null +++ b/src/llmq/quorumproofs.cpp @@ -0,0 +1,695 @@ +// Copyright (c) 2025 The Dash Core developers +// Distributed under the MIT/X11 software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using node::ReadBlockFromDisk; + +namespace llmq { + +// +// JSON Serialization helpers +// + +UniValue ChainlockProofEntry::ToJson() const +{ + UniValue obj(UniValue::VOBJ); + obj.pushKV("height", nHeight); + obj.pushKV("blockhash", blockHash.ToString()); + obj.pushKV("signature", signature.ToString()); + return obj; +} + +UniValue QuorumMerkleProof::ToJson() const +{ + UniValue obj(UniValue::VOBJ); + UniValue pathArr(UniValue::VARR); + for (const auto& hash : merklePath) { + pathArr.push_back(hash.ToString()); + } + obj.pushKV("merklePath", pathArr); + + UniValue sideArr(UniValue::VARR); + for (bool side : merklePathSide) { + sideArr.push_back(side); + } + obj.pushKV("merklePathSide", sideArr); + return obj; +} + +bool QuorumMerkleProof::Verify(const uint256& leafHash, const uint256& expectedRoot) const +{ + if (merklePath.size() != merklePathSide.size()) { + return false; + } + + // DoS protection: reject excessively long merkle paths + // A path longer than MAX_MERKLE_PATH_LENGTH would imply a tree with more than 2^32 leaves + if (merklePath.size() > MAX_MERKLE_PATH_LENGTH) { + return false; + } + + uint256 current = leafHash; + for (size_t i = 0; i < merklePath.size(); ++i) { + if (merklePathSide[i]) { + // Sibling is on the right + current = Hash(current, merklePath[i]); + } else { + // Sibling is on the left + current = Hash(merklePath[i], current); + } + } + + return current == expectedRoot; +} + +UniValue QuorumCommitmentProof::ToJson() const +{ + UniValue obj(UniValue::VOBJ); + obj.pushKV("commitment", commitment.ToJson()); + obj.pushKV("chainlockIndex", chainlockIndex); + obj.pushKV("quorumMerkleProof", quorumMerkleProof.ToJson()); + obj.pushKV("coinbaseTxHash", coinbaseTx ? coinbaseTx->GetHash().ToString() : ""); + + UniValue cbPathArr(UniValue::VARR); + for (const auto& hash : coinbaseMerklePath) { + cbPathArr.push_back(hash.ToString()); + } + obj.pushKV("coinbaseMerklePath", cbPathArr); + + UniValue cbSideArr(UniValue::VARR); + for (bool side : coinbaseMerklePathSide) { + cbSideArr.push_back(side); + } + obj.pushKV("coinbaseMerklePathSide", cbSideArr); + return obj; +} + +UniValue QuorumProofChain::ToJson() const +{ + UniValue obj(UniValue::VOBJ); + + UniValue headersArr(UniValue::VARR); + for (const auto& header : headers) { + UniValue hObj(UniValue::VOBJ); + hObj.pushKV("hash", header.GetHash().ToString()); + hObj.pushKV("version", header.nVersion); + hObj.pushKV("prevBlockHash", header.hashPrevBlock.ToString()); + hObj.pushKV("merkleRoot", header.hashMerkleRoot.ToString()); + hObj.pushKV("time", header.nTime); + hObj.pushKV("bits", header.nBits); + hObj.pushKV("nonce", header.nNonce); + headersArr.push_back(hObj); + } + obj.pushKV("headers", headersArr); + + UniValue chainlocksArr(UniValue::VARR); + for (const auto& cl : chainlocks) { + chainlocksArr.push_back(cl.ToJson()); + } + obj.pushKV("chainlocks", chainlocksArr); + + UniValue proofsArr(UniValue::VARR); + for (const auto& proof : quorumProofs) { + proofsArr.push_back(proof.ToJson()); + } + obj.pushKV("quorumProofs", proofsArr); + + return obj; +} + +UniValue QuorumCheckpoint::ToJson() const +{ + UniValue obj(UniValue::VOBJ); + obj.pushKV("blockHash", blockHash.ToString()); + obj.pushKV("height", height); + + UniValue quorumsArr(UniValue::VARR); + for (const auto& q : chainlockQuorums) { + UniValue qObj(UniValue::VOBJ); + qObj.pushKV("quorumHash", q.quorumHash.ToString()); + qObj.pushKV("quorumType", static_cast(q.quorumType)); + qObj.pushKV("publicKey", q.publicKey.ToString()); + quorumsArr.push_back(qObj); + } + obj.pushKV("chainlockQuorums", quorumsArr); + + return obj; +} + +QuorumCheckpoint QuorumCheckpoint::FromJson(const UniValue& obj) +{ + QuorumCheckpoint checkpoint; + + checkpoint.blockHash = uint256S(obj["blockHash"].get_str()); + checkpoint.height = obj["height"].getInt(); + + const UniValue& quorums = obj["chainlockQuorums"]; + for (size_t i = 0; i < quorums.size(); ++i) { + const UniValue& q = quorums[i]; + QuorumEntry entry; + entry.quorumHash = uint256S(q["quorumHash"].get_str()); + entry.quorumType = static_cast(q["quorumType"].getInt()); + if (!entry.publicKey.SetHexStr(q["publicKey"].get_str(), /*specificLegacyScheme=*/false)) { + throw std::runtime_error("Invalid publicKey in checkpoint"); + } + checkpoint.chainlockQuorums.push_back(entry); + } + + return checkpoint; +} + +UniValue QuorumProofVerifyResult::ToJson() const +{ + UniValue obj(UniValue::VOBJ); + obj.pushKV("valid", valid); + if (valid) { + obj.pushKV("quorumPublicKey", quorumPublicKey.ToString()); + } else { + obj.pushKV("error", error); + } + return obj; +} + +// +// CQuorumProofManager implementation +// + +void CQuorumProofManager::IndexChainlock(int32_t chainlockedHeight, const uint256& blockHash, + const CBLSSignature& signature, const uint256& cbtxBlockHash, + int32_t cbtxHeight) +{ + ChainlockIndexEntry entry; + entry.signature = signature; + entry.cbtxBlockHash = cbtxBlockHash; + entry.cbtxHeight = cbtxHeight; + + m_evoDb.Write(std::make_pair(DB_CHAINLOCK_BY_HEIGHT, chainlockedHeight), entry); +} + +void CQuorumProofManager::RemoveChainlockIndex(int32_t chainlockedHeight) +{ + m_evoDb.Erase(std::make_pair(DB_CHAINLOCK_BY_HEIGHT, chainlockedHeight)); +} + +std::optional CQuorumProofManager::GetChainlockByHeight(int32_t height) const +{ + ChainlockIndexEntry entry; + if (m_evoDb.Read(std::make_pair(DB_CHAINLOCK_BY_HEIGHT, height), entry)) { + return entry; + } + return std::nullopt; +} + +/** + * Verify a merkle proof by computing the root from a leaf hash and comparing to expected. + * @param leafHash The hash of the leaf element + * @param merklePath The sibling hashes from leaf to root + * @param merklePathSide Side indicators (true = sibling on right, false = sibling on left) + * @param expectedRoot The expected merkle root + * @return true if proof is valid + */ +static bool VerifyMerkleProof(const uint256& leafHash, + const std::vector& merklePath, + const std::vector& merklePathSide, + const uint256& expectedRoot) +{ + if (merklePath.size() != merklePathSide.size()) { + return false; + } + + if (merklePath.size() > MAX_MERKLE_PATH_LENGTH) { + return false; + } + + uint256 current = leafHash; + for (size_t i = 0; i < merklePath.size(); ++i) { + if (merklePathSide[i]) { + current = Hash(current, merklePath[i]); + } else { + current = Hash(merklePath[i], current); + } + } + + return current == expectedRoot; +} + +/** + * Helper function to build merkle proof with path tracking. + * Returns the merkle path (sibling hashes) and side indicators. + * + * The algorithm works by iteratively building each level of the merkle tree + * from leaves to root, tracking the target element's position at each level. + * + * At each level: + * - We pair up elements and hash them together + * - We record the sibling of our target element in the merkle path + * - We track where our combined hash will be in the next level + */ +static std::pair, std::vector> BuildMerkleProofPath( + const std::vector& hashes, size_t targetIndex) +{ + std::vector merklePath; + std::vector merklePathSide; + + if (hashes.empty()) { + return {merklePath, merklePathSide}; + } + + std::vector current = hashes; + size_t index = targetIndex; + + while (current.size() > 1) { + std::vector next; + size_t nextIndex = 0; + + for (size_t i = 0; i < current.size(); i += 2) { + size_t left = i; + size_t right = (i + 1 < current.size()) ? i + 1 : i; // Duplicate last if odd + + // Check if our target is in this pair + if (index == left || index == right) { + // Record the sibling and its position + if (index == left) { + merklePath.push_back(current[right]); + merklePathSide.push_back(true); // sibling is on right + } else { + merklePath.push_back(current[left]); + merklePathSide.push_back(false); // sibling is on left + } + // The combined hash will be at position next.size() in the next level + nextIndex = next.size(); + } + + next.push_back(Hash(current[left], current[right])); + } + + // Update index to track our element in the next level + index = nextIndex; + current = std::move(next); + } + + return {merklePath, merklePathSide}; +} + +std::optional CQuorumProofManager::BuildQuorumMerkleProof( + const CBlockIndex* pindex, + Consensus::LLMQType llmqType, + const uint256& quorumHash) const +{ + if (pindex == nullptr) { + return std::nullopt; + } + + // Get all active commitments at this block + auto commitmentsMap = m_quorum_block_processor.GetMinedAndActiveCommitmentsUntilBlock(pindex); + + // Collect all commitment hashes (matching CalcCbTxMerkleRootQuorums logic) + std::vector commitmentHashes; + uint256 targetCommitmentHash; + bool targetFound = false; + + for (const auto& [type, blockIndexes] : commitmentsMap) { + for (const auto* blockIndex : blockIndexes) { + auto [commitment, minedBlockHash] = m_quorum_block_processor.GetMinedCommitment(type, blockIndex->GetBlockHash()); + if (minedBlockHash == uint256::ZERO) { + continue; + } + + uint256 commitmentHash = ::SerializeHash(commitment); + commitmentHashes.push_back(commitmentHash); + + if (type == llmqType && commitment.quorumHash == quorumHash) { + targetCommitmentHash = commitmentHash; + targetFound = true; + } + } + } + + if (!targetFound) { + return std::nullopt; + } + + // Sort hashes to match CalcCbTxMerkleRootQuorums + std::sort(commitmentHashes.begin(), commitmentHashes.end()); + + // Find target index in sorted list + auto it = std::find(commitmentHashes.begin(), commitmentHashes.end(), targetCommitmentHash); + if (it == commitmentHashes.end()) { + return std::nullopt; + } + size_t targetIndex = std::distance(commitmentHashes.begin(), it); + + // Build the merkle proof + auto [path, side] = BuildMerkleProofPath(commitmentHashes, targetIndex); + + QuorumMerkleProof proof; + proof.merklePath = std::move(path); + proof.merklePathSide = std::move(side); + + return proof; +} + +int32_t CQuorumProofManager::FindChainlockCoveringBlock(const CBlockIndex* pMinedBlock) const +{ + if (pMinedBlock == nullptr) { + return -1; + } + + // Search for the first chainlock that covers this block + // A chainlock at height H covers all blocks from genesis to H + // We search forward from the mined block's height up to MAX_CHAINLOCK_SEARCH_OFFSET blocks + const int32_t maxHeight = pMinedBlock->nHeight + MAX_CHAINLOCK_SEARCH_OFFSET; + for (int32_t height = pMinedBlock->nHeight; height <= maxHeight; ++height) { + if (GetChainlockByHeight(height).has_value()) { + return height; + } + } + return -1; +} + +CQuorumCPtr CQuorumProofManager::DetermineChainlockSigningQuorum( + int32_t chainlockHeight, + const CChain& active_chain, + const CQuorumManager& qman) const +{ + // Get the chainlock LLMQ type from consensus parameters + const auto llmqType = Params().GetConsensus().llmqTypeChainLocks; + const auto& llmq_params_opt = Params().GetLLMQ(llmqType); + if (!llmq_params_opt.has_value()) { + return nullptr; + } + const auto& llmq_params = llmq_params_opt.value(); + + // Generate the request ID for the chainlock at this height + const uint256 requestId = chainlock::GenSigRequestId(chainlockHeight); + + // Use the existing SelectQuorumForSigning logic + return SelectQuorumForSigning(llmq_params, active_chain, qman, + requestId, chainlockHeight, SIGN_HEIGHT_OFFSET); +} + +std::optional CQuorumProofManager::BuildProofChain( + const QuorumCheckpoint& checkpoint, + Consensus::LLMQType targetQuorumType, + const uint256& targetQuorumHash, + const CQuorumManager& qman, + const CChain& active_chain) const +{ + // Phase 1: Build set of known chainlock quorum public keys from checkpoint + std::set knownQuorumPubKeys; + for (const auto& q : checkpoint.chainlockQuorums) { + knownQuorumPubKeys.insert(q.publicKey); + } + + // Phase 2: Work backwards from target to find the dependency chain + // Each ProofStep represents a quorum that needs to be proven and + // the chainlock height that covers its mined block + struct ProofStep { + CQuorumCPtr quorum; + int32_t chainlockHeight; + }; + std::vector proofSteps; + std::set visitedQuorums; // Cycle detection + + // Start with the target quorum + CQuorumCPtr currentQuorum = qman.GetQuorum(targetQuorumType, targetQuorumHash); + if (!currentQuorum) { + return std::nullopt; + } + + while (true) { + // Cycle detection + if (visitedQuorums.count(currentQuorum->qc->quorumHash)) { + return std::nullopt; // Cycle detected - invalid chain + } + visitedQuorums.insert(currentQuorum->qc->quorumHash); + + // DoS protection: limit chain length + if (proofSteps.size() >= MAX_PROOF_CHAIN_LENGTH) { + return std::nullopt; + } + + // Find the first chainlock that covers this quorum's mined block + const CBlockIndex* pMinedBlock = currentQuorum->m_quorum_base_block_index; + if (!pMinedBlock) { + return std::nullopt; + } + + int32_t chainlockHeight = FindChainlockCoveringBlock(pMinedBlock); + if (chainlockHeight < 0) { + return std::nullopt; // No chainlock found covering this quorum + } + + proofSteps.push_back({currentQuorum, chainlockHeight}); + + // Determine which quorum signed this chainlock + CQuorumCPtr signingQuorum = DetermineChainlockSigningQuorum(chainlockHeight, active_chain, qman); + if (!signingQuorum) { + return std::nullopt; // Could not determine signing quorum + } + + // Check if the signing quorum's public key is in the checkpoint's known quorums + if (knownQuorumPubKeys.count(signingQuorum->qc->quorumPublicKey)) { + // We've reached a quorum that's trusted by the checkpoint - done! + break; + } + + // The signing quorum is not in the checkpoint, so we need to prove it first + currentQuorum = signingQuorum; + } + + // Phase 3: Build proofs in forward order (reverse the dependency chain) + std::reverse(proofSteps.begin(), proofSteps.end()); + + // Phase 4: Construct the QuorumProofChain + QuorumProofChain chain; + std::set includedChainlockHeights; + + for (const auto& step : proofSteps) { + // Add chainlock entry if not already included + if (!includedChainlockHeights.count(step.chainlockHeight)) { + auto clEntry = GetChainlockByHeight(step.chainlockHeight); + if (!clEntry.has_value()) { + return std::nullopt; + } + + // Get the block hash at the chainlock height + const CBlockIndex* pClBlock = step.quorum->m_quorum_base_block_index->GetAncestor(step.chainlockHeight); + if (!pClBlock) { + return std::nullopt; + } + + ChainlockProofEntry clProof; + clProof.nHeight = step.chainlockHeight; + clProof.blockHash = pClBlock->GetBlockHash(); + clProof.signature = clEntry->signature; + chain.chainlocks.push_back(clProof); + includedChainlockHeights.insert(step.chainlockHeight); + } + + // Build the quorum commitment proof + const CBlockIndex* pMinedBlock = step.quorum->m_quorum_base_block_index; + + auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.quorum->qc->llmqType, step.quorum->qc->quorumHash); + if (!merkleProof.has_value()) { + return std::nullopt; + } + + // Read the block to get coinbase transaction + CBlock block; + if (!ReadBlockFromDisk(block, pMinedBlock, Params().GetConsensus())) { + return std::nullopt; + } + + // Build coinbase merkle proof + std::vector txHashes; + for (const auto& tx : block.vtx) { + txHashes.push_back(tx->GetHash()); + } + + auto [cbPath, cbSide] = BuildMerkleProofPath(txHashes, 0); // Coinbase is at index 0 + + // Find the chainlock index for this proof step + uint32_t chainlockIndex = 0; + for (size_t i = 0; i < chain.chainlocks.size(); ++i) { + if (chain.chainlocks[i].nHeight == step.chainlockHeight) { + chainlockIndex = static_cast(i); + break; + } + } + + QuorumCommitmentProof commitmentProof; + commitmentProof.commitment = *step.quorum->qc; + commitmentProof.chainlockIndex = chainlockIndex; + commitmentProof.quorumMerkleProof = merkleProof.value(); + commitmentProof.coinbaseTx = block.vtx[0]; + commitmentProof.coinbaseMerklePath = std::move(cbPath); + commitmentProof.coinbaseMerklePathSide = std::move(cbSide); + + chain.quorumProofs.push_back(commitmentProof); + + // Add the block header + chain.headers.push_back(block.GetBlockHeader()); + } + + return chain; +} + +QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( + const QuorumCheckpoint& checkpoint, + const QuorumProofChain& proof, + Consensus::LLMQType expectedType, + const uint256& expectedQuorumHash) const +{ + QuorumProofVerifyResult result; + + // DoS protection: limit proof chain length + if (proof.quorumProofs.size() > MAX_PROOF_CHAIN_LENGTH) { + result.error = "Proof chain exceeds maximum length"; + return result; + } + + if (proof.chainlocks.empty() || proof.quorumProofs.empty()) { + result.error = "Proof chain is empty"; + return result; + } + + if (proof.headers.size() != proof.quorumProofs.size()) { + result.error = "Headers count does not match quorum proofs count"; + return result; + } + + // Verify header chain continuity - each header's prevBlockHash must match the previous header's hash + // This prevents an attacker from mixing headers from different blockchain forks + for (size_t i = 1; i < proof.headers.size(); ++i) { + if (proof.headers[i].hashPrevBlock != proof.headers[i - 1].GetHash()) { + result.error = "Header chain is not continuous - prevBlockHash mismatch at index " + std::to_string(i); + return result; + } + } + + // Phase 1: Build initial set of known chainlock quorum public keys from checkpoint + // We use a set of public keys since that's what we actually verify signatures against + std::set knownQuorumPubKeys; + for (const auto& q : checkpoint.chainlockQuorums) { + knownQuorumPubKeys.insert(q.publicKey); + } + + // Phase 2: Process quorum proofs IN ORDER + // Each proven quorum adds its public key to the known set for subsequent proofs + // This allows bridging: checkpoint proves A, A proves B, B proves C (target) + const QuorumCommitmentProof* targetProof = nullptr; + std::set verifiedChainlockHeights; + + for (size_t proofIdx = 0; proofIdx < proof.quorumProofs.size(); ++proofIdx) { + const auto& qProof = proof.quorumProofs[proofIdx]; + + // Get the chainlock that covers this commitment + if (qProof.chainlockIndex >= proof.chainlocks.size()) { + result.error = "Invalid chainlock index " + std::to_string(qProof.chainlockIndex); + return result; + } + const auto& chainlock = proof.chainlocks[qProof.chainlockIndex]; + + // Verify chainlock signature if we haven't verified this chainlock yet + if (!verifiedChainlockHeights.count(chainlock.nHeight)) { + if (!chainlock.signature.IsValid()) { + result.error = "Invalid chainlock signature format at height " + std::to_string(chainlock.nHeight); + return result; + } + + // Verify the chainlock signature against current known quorum keys + // For chainlocks, the message being signed is the block hash + // Try both BLS schemes (non-legacy post-v19, legacy pre-v19) + const auto verifyAgainstKey = [&chainlock](const CBLSPublicKey& pubKey) { + return chainlock.signature.VerifyInsecure(pubKey, chainlock.blockHash, /*specificLegacyScheme=*/false) || + chainlock.signature.VerifyInsecure(pubKey, chainlock.blockHash, /*specificLegacyScheme=*/true); + }; + + const bool signatureVerified = std::any_of(knownQuorumPubKeys.begin(), knownQuorumPubKeys.end(), verifyAgainstKey); + + if (!signatureVerified) { + result.error = "Chainlock signature verification failed at height " + + std::to_string(chainlock.nHeight) + + " - signature does not match any known quorum key"; + return result; + } + + verifiedChainlockHeights.insert(chainlock.nHeight); + } + + // Get the corresponding header for this quorum proof + const CBlockHeader& header = proof.headers[proofIdx]; + + // Verify coinbase tx is in the block via merkle proof + if (!qProof.coinbaseTx) { + result.error = "Missing coinbase transaction in proof " + std::to_string(proofIdx); + return result; + } + + const uint256 coinbaseTxHash = qProof.coinbaseTx->GetHash(); + if (!VerifyMerkleProof(coinbaseTxHash, qProof.coinbaseMerklePath, + qProof.coinbaseMerklePathSide, header.hashMerkleRoot)) { + result.error = "Coinbase merkle proof verification failed in proof " + std::to_string(proofIdx); + return result; + } + + // Extract merkleRootQuorums from cbtx + auto opt_cbtx = GetTxPayload(*qProof.coinbaseTx); + if (!opt_cbtx.has_value()) { + result.error = "Invalid coinbase transaction payload in proof " + std::to_string(proofIdx); + return result; + } + + const CCbTx& cbtx = opt_cbtx.value(); + + // Verify the quorum commitment merkle proof against merkleRootQuorums + uint256 commitmentHash = ::SerializeHash(qProof.commitment); + if (!qProof.quorumMerkleProof.Verify(commitmentHash, cbtx.merkleRootQuorums)) { + result.error = "Quorum commitment merkle proof verification failed in proof " + std::to_string(proofIdx); + return result; + } + + // This quorum is now proven! Add its public key to known keys for subsequent proofs + knownQuorumPubKeys.insert(qProof.commitment.quorumPublicKey); + + // Check if this is the target quorum + if (qProof.commitment.llmqType == expectedType && + qProof.commitment.quorumHash == expectedQuorumHash) { + targetProof = &qProof; + } + } + + // Phase 3: Verify target quorum was proven + if (!targetProof) { + result.error = "Target quorum not found in proof chain"; + return result; + } + + // All verifications passed + result.valid = true; + result.quorumPublicKey = targetProof->commitment.quorumPublicKey; + + return result; +} + +} // namespace llmq diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h new file mode 100644 index 000000000000..8662680962f1 --- /dev/null +++ b/src/llmq/quorumproofs.h @@ -0,0 +1,226 @@ +// Copyright (c) 2025 The Dash Core developers +// Distributed under the MIT/X11 software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_LLMQ_QUORUMPROOFS_H +#define BITCOIN_LLMQ_QUORUMPROOFS_H + +#include +#include +#include +#include +#include +#include +#include + +#include + +class CBlockIndex; +class CChain; +class CEvoDB; + +namespace llmq { + +class CQuorumBlockProcessor; +class CQuorumManager; + +/** + * Entry stored in the chainlock index database. + * Maps chainlocked height to the signature and where it was embedded. + */ +struct ChainlockIndexEntry { + CBLSSignature signature; + uint256 cbtxBlockHash; // Block where this chainlock was embedded in cbtx + int32_t cbtxHeight{0}; // Height of that block + + SERIALIZE_METHODS(ChainlockIndexEntry, obj) { + READWRITE(obj.signature, obj.cbtxBlockHash, obj.cbtxHeight); + } +}; + +/** + * A chainlock proof entry for the proof chain. + * Contains the chainlock signature for a specific block. + */ +struct ChainlockProofEntry { + int32_t nHeight{0}; + uint256 blockHash; + CBLSSignature signature; + + SERIALIZE_METHODS(ChainlockProofEntry, obj) { + READWRITE(obj.nHeight, obj.blockHash, obj.signature); + } + + [[nodiscard]] UniValue ToJson() const; +}; + +/** + * Merkle proof for a quorum commitment within the merkleRootQuorums. + * Allows verification that a commitment is included in a block's cbtx. + */ +struct QuorumMerkleProof { + std::vector merklePath; // Sibling hashes from leaf to root + std::vector merklePathSide; // true = right sibling, false = left + + SERIALIZE_METHODS(QuorumMerkleProof, obj) { + READWRITE(obj.merklePath, DYNBITSET(obj.merklePathSide)); + } + + /** + * Verify the merkle proof for a given leaf hash against an expected root. + * @param leafHash The hash of the commitment (SerializeHash of CFinalCommitment) + * @param expectedRoot The merkleRootQuorums from the cbtx + * @return true if the proof is valid + */ + [[nodiscard]] bool Verify(const uint256& leafHash, const uint256& expectedRoot) const; + + [[nodiscard]] UniValue ToJson() const; +}; + +/** + * Complete proof for a single quorum commitment. + * Links a commitment to a chainlocked block via merkle proofs. + */ +struct QuorumCommitmentProof { + CFinalCommitment commitment; + uint32_t chainlockIndex{0}; // Index into chainlocks array that covers this commitment + QuorumMerkleProof quorumMerkleProof; // Proof within merkleRootQuorums + CTransactionRef coinbaseTx; // The coinbase transaction containing merkleRootQuorums + std::vector coinbaseMerklePath; // Proof that coinbaseTx is in block's merkle root + std::vector coinbaseMerklePathSide; + + SERIALIZE_METHODS(QuorumCommitmentProof, obj) { + READWRITE(obj.commitment, obj.chainlockIndex, + obj.quorumMerkleProof, + obj.coinbaseTx, obj.coinbaseMerklePath, DYNBITSET(obj.coinbaseMerklePathSide)); + } + + [[nodiscard]] UniValue ToJson() const; +}; + +/** + * Complete proof chain from a checkpoint to a target quorum. + * Contains all data needed to verify a quorum's public key starting from + * known chainlock quorums. + */ +struct QuorumProofChain { + std::vector headers; + std::vector chainlocks; + std::vector quorumProofs; + + SERIALIZE_METHODS(QuorumProofChain, obj) { + READWRITE(obj.headers, obj.chainlocks, obj.quorumProofs); + } + + [[nodiscard]] UniValue ToJson() const; +}; + +/** + * Checkpoint data provided by the verifier. + * Contains known trusted chainlock quorum public keys. + */ +struct QuorumCheckpoint { + uint256 blockHash; + int32_t height{0}; + struct QuorumEntry { + uint256 quorumHash; + Consensus::LLMQType quorumType{Consensus::LLMQType::LLMQ_NONE}; + CBLSPublicKey publicKey; + + SERIALIZE_METHODS(QuorumEntry, obj) { + READWRITE(obj.quorumHash, obj.quorumType, obj.publicKey); + } + }; + std::vector chainlockQuorums; + + SERIALIZE_METHODS(QuorumCheckpoint, obj) { + READWRITE(obj.blockHash, obj.height, obj.chainlockQuorums); + } + + [[nodiscard]] UniValue ToJson() const; + static QuorumCheckpoint FromJson(const UniValue& obj); +}; + +/** + * Result of proof chain verification. + */ +struct QuorumProofVerifyResult { + bool valid{false}; + CBLSPublicKey quorumPublicKey; + std::string error; + + [[nodiscard]] UniValue ToJson() const; +}; + +/** + * Manager for chainlock indexing and quorum proof generation/verification. + */ +class CQuorumProofManager { +private: + CEvoDB& m_evoDb; + const CQuorumBlockProcessor& m_quorum_block_processor; + + // Helper to determine which quorum signed a chainlock at a given height + [[nodiscard]] CQuorumCPtr DetermineChainlockSigningQuorum( + int32_t chainlockHeight, + const CChain& active_chain, + const CQuorumManager& qman) const; + + // Helper to find the first chainlock covering a block + [[nodiscard]] int32_t FindChainlockCoveringBlock(const CBlockIndex* pMinedBlock) const; + +public: + CQuorumProofManager(CEvoDB& evoDb, const CQuorumBlockProcessor& quorum_block_processor) + : m_evoDb(evoDb), m_quorum_block_processor(quorum_block_processor) {} + + CQuorumProofManager() = delete; + CQuorumProofManager(const CQuorumProofManager&) = delete; + CQuorumProofManager& operator=(const CQuorumProofManager&) = delete; + + // Chainlock Index Management + void IndexChainlock(int32_t chainlockedHeight, const uint256& blockHash, + const CBLSSignature& signature, const uint256& cbtxBlockHash, + int32_t cbtxHeight); + void RemoveChainlockIndex(int32_t chainlockedHeight); + [[nodiscard]] std::optional GetChainlockByHeight(int32_t height) const; + + // Merkle Proof Building + [[nodiscard]] std::optional BuildQuorumMerkleProof( + const CBlockIndex* pindex, + Consensus::LLMQType llmqType, + const uint256& quorumHash) const; + + // Proof Chain Generation + [[nodiscard]] std::optional BuildProofChain( + const QuorumCheckpoint& checkpoint, + Consensus::LLMQType targetQuorumType, + const uint256& targetQuorumHash, + const CQuorumManager& qman, + const CChain& active_chain) const; + + // Proof Chain Verification + [[nodiscard]] QuorumProofVerifyResult VerifyProofChain( + const QuorumCheckpoint& checkpoint, + const QuorumProofChain& proof, + Consensus::LLMQType expectedType, + const uint256& expectedQuorumHash) const; +}; + +// Database key prefix for chainlock index +static const std::string DB_CHAINLOCK_BY_HEIGHT = "q_clh"; + +// Maximum merkle path length (DoS protection) +// A path of 32 levels can support 2^32 leaves, which is more than sufficient +static constexpr size_t MAX_MERKLE_PATH_LENGTH = 32; + +// Maximum proof chain length (DoS protection) +// Limits how many intermediate quorums can be proven in a single chain +static constexpr size_t MAX_PROOF_CHAIN_LENGTH = 50; + +// Maximum height offset to search for a chainlock covering a block +// This limits how far forward we search from a block's height to find coverage +static constexpr int32_t MAX_CHAINLOCK_SEARCH_OFFSET = 100; + +} // namespace llmq + +#endif // BITCOIN_LLMQ_QUORUMPROOFS_H diff --git a/src/node/chainstate.cpp b/src/node/chainstate.cpp index e60eaace5630..e416caaafbdf 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -249,7 +249,7 @@ void DashChainstateSetup(ChainstateManager& chainman, chain_helper.reset(); chain_helper = std::make_unique(*cpoolman, *dmnman, *mnhf_manager, govman, *(llmq_ctx->isman), *(llmq_ctx->quorum_block_processor), *(llmq_ctx->qsnapman), chainman, consensus_params, mn_sync, sporkman, *(llmq_ctx->clhandler), - *(llmq_ctx->qman)); + *(llmq_ctx->qman), *(llmq_ctx->quorum_proof_manager)); } void DashChainstateSetupClose(std::unique_ptr& chain_helper, diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index f82ec32def8d..55a755466cdb 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -1246,6 +1247,230 @@ static RPCHelpMan submitchainlock() } +static RPCHelpMan getchainlockbyheight() +{ + return RPCHelpMan{"getchainlockbyheight", + "Get the chainlock for a specific height from the index.\n", + { + {"height", RPCArg::Type::NUM, RPCArg::Optional::NO, "Block height"}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::NUM, "height", "Chainlocked height"}, + {RPCResult::Type::STR_HEX, "blockhash", "Block hash"}, + {RPCResult::Type::STR_HEX, "signature", "BLS signature"}, + {RPCResult::Type::NUM, "cbtx_height", "Height where CL was embedded"}, + }}, + RPCExamples{ + HelpExampleCli("getchainlockbyheight", "100") + + HelpExampleRpc("getchainlockbyheight", "100") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue +{ + const int height = request.params[0].getInt(); + if (height < 0) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "height must be non-negative"); + } + + const NodeContext& node = EnsureAnyNodeContext(request.context); + const ChainstateManager& chainman = EnsureChainman(node); + const LLMQContext& llmq_ctx = EnsureLLMQContext(node); + + auto entry = llmq_ctx.quorum_proof_manager->GetChainlockByHeight(height); + if (!entry.has_value()) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Chainlock not found for height"); + } + + // Get the block hash at the chainlocked height + uint256 blockHash; + { + LOCK(cs_main); + const CBlockIndex* pindex = chainman.ActiveChain()[height]; + if (pindex == nullptr) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Block not found at height"); + } + blockHash = pindex->GetBlockHash(); + } + + UniValue result(UniValue::VOBJ); + result.pushKV("height", height); + result.pushKV("blockhash", blockHash.ToString()); + result.pushKV("signature", entry->signature.ToString()); + result.pushKV("cbtx_height", entry->cbtxHeight); + + return result; +}, + }; +} + +/** + * Parse a QuorumCheckpoint from RPC JSON object. + * Used by both getquorumproofchain and verifyquorumproofchain. + */ +static llmq::QuorumCheckpoint ParseCheckpointFromRPC(const UniValue& checkpointObj) +{ + llmq::QuorumCheckpoint checkpoint; + checkpoint.blockHash = ParseHashV(checkpointObj["block_hash"], "block_hash"); + checkpoint.height = checkpointObj["height"].getInt(); + + const UniValue& quorumsArr = checkpointObj["chainlock_quorums"].get_array(); + for (size_t i = 0; i < quorumsArr.size(); ++i) { + const UniValue& q = quorumsArr[i]; + llmq::QuorumCheckpoint::QuorumEntry entry; + entry.quorumHash = ParseHashV(q["quorum_hash"], "quorum_hash"); + entry.quorumType = static_cast(q["quorum_type"].getInt()); + if (!entry.publicKey.SetHexStr(q["public_key"].get_str(), /*specificLegacyScheme=*/false)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid public_key format"); + } + checkpoint.chainlockQuorums.push_back(entry); + } + + return checkpoint; +} + +static RPCHelpMan getquorumproofchain() +{ + return RPCHelpMan{"getquorumproofchain", + "Generate a proof chain from a checkpoint to a target quorum.\n" + "This proof can be used to trustlessly verify a quorum's public key.\n", + { + {"checkpoint", RPCArg::Type::OBJ, RPCArg::Optional::NO, "Checkpoint data", + { + {"block_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Checkpoint block hash"}, + {"height", RPCArg::Type::NUM, RPCArg::Optional::NO, "Checkpoint height"}, + {"chainlock_quorums", RPCArg::Type::ARR, RPCArg::Optional::NO, "Known CL quorum hashes and public keys", + { + {"", RPCArg::Type::OBJ, RPCArg::Optional::NO, "", + { + {"quorum_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Quorum hash"}, + {"quorum_type", RPCArg::Type::NUM, RPCArg::Optional::NO, "LLMQ type"}, + {"public_key", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Quorum public key"}, + }, + }, + }, + }, + }, + }, + {"quorum_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Target quorum hash"}, + {"llmq_type", RPCArg::Type::NUM, RPCArg::Optional::NO, "Target LLMQ type"}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::ARR, "headers", "Block headers in the proof chain", + {{RPCResult::Type::OBJ, "", false, "Header object"}}}, + {RPCResult::Type::ARR, "chainlocks", "Chainlock proofs", + {{RPCResult::Type::OBJ, "", false, "Chainlock entry"}}}, + {RPCResult::Type::ARR, "quorum_proofs", "Quorum commitment proofs", + {{RPCResult::Type::OBJ, "", false, "Quorum proof entry"}}}, + {RPCResult::Type::STR_HEX, "proof_hex", "Serialized proof (hex)"}, + }}, + RPCExamples{ + HelpExampleCli("getquorumproofchain", "'{\"block_hash\":\"...\",\"height\":100,\"chainlock_quorums\":[]}' \"abcd...\" 104") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue +{ + const NodeContext& node = EnsureAnyNodeContext(request.context); + const LLMQContext& llmq_ctx = EnsureLLMQContext(node); + const ChainstateManager& chainman = EnsureChainman(node); + + const llmq::QuorumCheckpoint checkpoint = ParseCheckpointFromRPC(request.params[0].get_obj()); + + const uint256 targetQuorumHash = ParseHashV(request.params[1], "quorum_hash"); + const Consensus::LLMQType targetType = static_cast(request.params[2].getInt()); + + if (!Params().GetLLMQ(targetType).has_value()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid LLMQ type"); + } + + auto proofChain = llmq_ctx.quorum_proof_manager->BuildProofChain( + checkpoint, targetType, targetQuorumHash, *llmq_ctx.qman, chainman.ActiveChain()); + + if (!proofChain.has_value()) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Failed to build proof chain - quorum not found or no chainlock coverage"); + } + + UniValue result = proofChain->ToJson(); + + // Add serialized hex + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + ss << proofChain.value(); + result.pushKV("proof_hex", HexStr(ss)); + + return result; +}, + }; +} + +static RPCHelpMan verifyquorumproofchain() +{ + return RPCHelpMan{"verifyquorumproofchain", + "Verify a quorum proof chain and return the target quorum's public key.\n", + { + {"checkpoint", RPCArg::Type::OBJ, RPCArg::Optional::NO, "Checkpoint data", + { + {"block_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Checkpoint block hash"}, + {"height", RPCArg::Type::NUM, RPCArg::Optional::NO, "Checkpoint height"}, + {"chainlock_quorums", RPCArg::Type::ARR, RPCArg::Optional::NO, "Known CL quorum hashes and public keys", + { + {"", RPCArg::Type::OBJ, RPCArg::Optional::NO, "", + { + {"quorum_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Quorum hash"}, + {"quorum_type", RPCArg::Type::NUM, RPCArg::Optional::NO, "LLMQ type"}, + {"public_key", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Quorum public key"}, + }, + }, + }, + }, + }, + }, + {"proof_hex", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Serialized proof chain (hex)"}, + {"quorum_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Expected target quorum hash"}, + {"llmq_type", RPCArg::Type::NUM, RPCArg::Optional::NO, "Expected LLMQ type"}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::BOOL, "valid", "Whether the proof is valid"}, + {RPCResult::Type::STR_HEX, "quorum_public_key", /* optional */ true, "Verified public key (if valid)"}, + {RPCResult::Type::STR, "error", /* optional */ true, "Error message (if invalid)"}, + }}, + RPCExamples{ + HelpExampleCli("verifyquorumproofchain", "'{...}' \"proof_hex\" \"quorum_hash\" 104") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue +{ + const NodeContext& node = EnsureAnyNodeContext(request.context); + const LLMQContext& llmq_ctx = EnsureLLMQContext(node); + + const llmq::QuorumCheckpoint checkpoint = ParseCheckpointFromRPC(request.params[0].get_obj()); + + // Deserialize the proof + const std::vector proofData = ParseHex(request.params[1].get_str()); + CDataStream ss(proofData, SER_NETWORK, PROTOCOL_VERSION); + + llmq::QuorumProofChain proofChain; + try { + ss >> proofChain; + } catch (const std::exception& e) { + UniValue result(UniValue::VOBJ); + result.pushKV("valid", false); + result.pushKV("error", strprintf("Failed to deserialize proof: %s", e.what())); + return result; + } + + const uint256 expectedQuorumHash = ParseHashV(request.params[2], "quorum_hash"); + const Consensus::LLMQType expectedType = static_cast(request.params[3].getInt()); + + auto verifyResult = llmq_ctx.quorum_proof_manager->VerifyProofChain( + checkpoint, proofChain, expectedType, expectedQuorumHash); + + return verifyResult.ToJson(); +}, + }; +} + void RegisterQuorumsRPCCommands(CRPCTable &tableRPC) { static const CRPCCommand commands[]{ @@ -1269,6 +1494,9 @@ void RegisterQuorumsRPCCommands(CRPCTable &tableRPC) {"evo", &submitchainlock}, {"evo", &verifychainlock}, {"evo", &verifyislock}, + {"evo", &getchainlockbyheight}, + {"evo", &getquorumproofchain}, + {"evo", &verifyquorumproofchain}, }; for (const auto& command : commands) { tableRPC.appendCommand(command.name, &command); diff --git a/src/test/quorum_proofs_tests.cpp b/src/test/quorum_proofs_tests.cpp new file mode 100644 index 000000000000..579e9faf96fe --- /dev/null +++ b/src/test/quorum_proofs_tests.cpp @@ -0,0 +1,572 @@ +// Copyright (c) 2025 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +BOOST_FIXTURE_TEST_SUITE(quorum_proofs_tests, BasicTestingSetup) + +// Helper function to create test hashes +static uint256 MakeTestHash(int n) +{ + std::vector data(32, 0); + data[0] = static_cast(n); + return uint256(data); +} + +// Test QuorumMerkleProof verification +BOOST_AUTO_TEST_CASE(quorum_merkle_proof_verify) +{ + // Test case with 4 leaves + std::vector leaves = { + MakeTestHash(1), + MakeTestHash(2), + MakeTestHash(3), + MakeTestHash(4) + }; + + std::sort(leaves.begin(), leaves.end()); + + // Manually compute the merkle tree + // Level 0: leaves + // Level 1: H(leaf0, leaf1), H(leaf2, leaf3) + // Level 2: root = H(level1[0], level1[1]) + uint256 h01 = Hash(leaves[0], leaves[1]); + uint256 h23 = Hash(leaves[2], leaves[3]); + uint256 root = Hash(h01, h23); + + // Build proof for leaf 0: sibling is leaf1, then h23 + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaves[1], h23}; + proof.merklePathSide = {true, true}; // both siblings are on the right + + BOOST_CHECK(proof.Verify(leaves[0], root)); + + // Test with wrong root - should fail + uint256 wrongRoot = MakeTestHash(99); + BOOST_CHECK(!proof.Verify(leaves[0], wrongRoot)); + + // Test with wrong leaf - should fail + uint256 wrongLeaf = MakeTestHash(100); + BOOST_CHECK(!proof.Verify(wrongLeaf, root)); +} + +// Test QuorumMerkleProof with single leaf +BOOST_AUTO_TEST_CASE(quorum_merkle_proof_single_leaf) +{ + uint256 leaf = MakeTestHash(1); + + // With a single leaf, the merkle root is just Hash(leaf, leaf) + uint256 root = Hash(leaf, leaf); + + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaf}; // self-duplicate + proof.merklePathSide = {true}; + + BOOST_CHECK(proof.Verify(leaf, root)); +} + +// Test QuorumMerkleProof with odd number of leaves +BOOST_AUTO_TEST_CASE(quorum_merkle_proof_odd_count) +{ + std::vector leaves = { + MakeTestHash(1), + MakeTestHash(2), + MakeTestHash(3) + }; + + std::sort(leaves.begin(), leaves.end()); + + // With 3 leaves: + // Level 0: leaf0, leaf1, leaf2 + // Level 1: H(leaf0, leaf1), H(leaf2, leaf2) <-- leaf2 duplicated + // Level 2: root + uint256 h01 = Hash(leaves[0], leaves[1]); + uint256 h22 = Hash(leaves[2], leaves[2]); + uint256 root = Hash(h01, h22); + + // Build proof for leaf 2: self-duplicate at level 0, then h01 at level 1 + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaves[2], h01}; + proof.merklePathSide = {true, false}; // self on right, h01 on left + + BOOST_CHECK(proof.Verify(leaves[2], root)); +} + +// Test ChainlockProofEntry serialization +BOOST_AUTO_TEST_CASE(chainlock_proof_entry_serialization) +{ + llmq::ChainlockProofEntry entry; + entry.nHeight = 12345; + entry.blockHash = MakeTestHash(42); + + // Create a valid BLS signature by signing with a real key + CBLSSecretKey sk; + sk.MakeNewKey(); + entry.signature = sk.Sign(entry.blockHash, /*specificLegacyScheme=*/false); + + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + ss << entry; + + llmq::ChainlockProofEntry deserialized; + ss >> deserialized; + + BOOST_CHECK_EQUAL(entry.nHeight, deserialized.nHeight); + BOOST_CHECK(entry.blockHash == deserialized.blockHash); + BOOST_CHECK(entry.signature == deserialized.signature); +} + +// Test QuorumProofChain serialization roundtrip +BOOST_AUTO_TEST_CASE(quorum_proof_chain_serialization) +{ + llmq::QuorumProofChain chain; + + // Add a test header + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = MakeTestHash(1); + header.hashMerkleRoot = MakeTestHash(2); + header.nTime = 1234567890; + header.nBits = 0x1d00ffff; + header.nNonce = 12345; + chain.headers.push_back(header); + + // Add a chainlock entry + llmq::ChainlockProofEntry clEntry; + clEntry.nHeight = 100; + clEntry.blockHash = MakeTestHash(3); + chain.chainlocks.push_back(clEntry); + + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + ss << chain; + + llmq::QuorumProofChain deserialized; + ss >> deserialized; + + BOOST_CHECK_EQUAL(chain.headers.size(), deserialized.headers.size()); + BOOST_CHECK_EQUAL(chain.chainlocks.size(), deserialized.chainlocks.size()); + BOOST_CHECK_EQUAL(chain.quorumProofs.size(), deserialized.quorumProofs.size()); + + if (!chain.headers.empty()) { + BOOST_CHECK(chain.headers[0].GetHash() == deserialized.headers[0].GetHash()); + } + if (!chain.chainlocks.empty()) { + BOOST_CHECK_EQUAL(chain.chainlocks[0].nHeight, deserialized.chainlocks[0].nHeight); + BOOST_CHECK(chain.chainlocks[0].blockHash == deserialized.chainlocks[0].blockHash); + } +} + +// Test ChainlockIndexEntry serialization +BOOST_AUTO_TEST_CASE(chainlock_index_entry_serialization) +{ + llmq::ChainlockIndexEntry entry; + entry.cbtxBlockHash = MakeTestHash(10); + entry.cbtxHeight = 500; + + // Create a valid BLS signature by signing with a real key + CBLSSecretKey sk; + sk.MakeNewKey(); + entry.signature = sk.Sign(entry.cbtxBlockHash, /*specificLegacyScheme=*/false); + + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + ss << entry; + + llmq::ChainlockIndexEntry deserialized; + ss >> deserialized; + + BOOST_CHECK(entry.cbtxBlockHash == deserialized.cbtxBlockHash); + BOOST_CHECK_EQUAL(entry.cbtxHeight, deserialized.cbtxHeight); + BOOST_CHECK(entry.signature == deserialized.signature); +} + +// Test QuorumCheckpoint JSON roundtrip +BOOST_AUTO_TEST_CASE(quorum_checkpoint_json_roundtrip) +{ + llmq::QuorumCheckpoint checkpoint; + checkpoint.blockHash = MakeTestHash(20); + checkpoint.height = 1000; + + llmq::QuorumCheckpoint::QuorumEntry qEntry; + qEntry.quorumHash = MakeTestHash(21); + qEntry.quorumType = Consensus::LLMQType::LLMQ_TEST; + + // Create a valid BLS public key from a real secret key + CBLSSecretKey sk; + sk.MakeNewKey(); + qEntry.publicKey = sk.GetPublicKey(); + + checkpoint.chainlockQuorums.push_back(qEntry); + + UniValue json = checkpoint.ToJson(); + + // Verify structure + BOOST_CHECK(json.exists("blockHash")); + BOOST_CHECK(json.exists("height")); + BOOST_CHECK(json.exists("chainlockQuorums")); + + BOOST_CHECK_EQUAL(json["height"].getInt(), 1000); +} + +// Test QuorumProofVerifyResult +BOOST_AUTO_TEST_CASE(quorum_proof_verify_result_json) +{ + // Test valid result + llmq::QuorumProofVerifyResult validResult; + validResult.valid = true; + + // Create a valid BLS public key from a real secret key + CBLSSecretKey sk; + sk.MakeNewKey(); + validResult.quorumPublicKey = sk.GetPublicKey(); + + UniValue validJson = validResult.ToJson(); + BOOST_CHECK(validJson["valid"].get_bool()); + BOOST_CHECK(validJson.exists("quorumPublicKey")); + + // Test invalid result + llmq::QuorumProofVerifyResult invalidResult; + invalidResult.valid = false; + invalidResult.error = "Test error message"; + + UniValue invalidJson = invalidResult.ToJson(); + BOOST_CHECK(!invalidJson["valid"].get_bool()); + BOOST_CHECK_EQUAL(invalidJson["error"].get_str(), "Test error message"); +} + +// Test merkle path side indicators are consistent +BOOST_AUTO_TEST_CASE(quorum_merkle_proof_side_consistency) +{ + llmq::QuorumMerkleProof proof; + + // Mismatched path and side vectors should fail verification + proof.merklePath = {MakeTestHash(1), MakeTestHash(2)}; + proof.merklePathSide = {true}; // Only one side indicator for two path elements + + BOOST_CHECK(!proof.Verify(MakeTestHash(0), MakeTestHash(99))); +} + +// Test DoS protection: paths longer than MAX_MERKLE_PATH_LENGTH should be rejected +BOOST_AUTO_TEST_CASE(merkle_proof_dos_protection) +{ + llmq::QuorumMerkleProof proof; + + // Create a path that exceeds MAX_MERKLE_PATH_LENGTH (32) + // Such a path would imply a tree with 2^33+ leaves, which is unreasonable + for (size_t i = 0; i <= llmq::MAX_MERKLE_PATH_LENGTH; ++i) { + proof.merklePath.push_back(MakeTestHash(static_cast(i))); + proof.merklePathSide.push_back(true); + } + + // This should be rejected due to DoS protection, regardless of hash validity + BOOST_CHECK(!proof.Verify(MakeTestHash(100), MakeTestHash(200))); + + // Verify that paths at exactly the limit are still processed (not rejected for length) + llmq::QuorumMerkleProof atLimitProof; + for (size_t i = 0; i < llmq::MAX_MERKLE_PATH_LENGTH; ++i) { + atLimitProof.merklePath.push_back(MakeTestHash(static_cast(i))); + atLimitProof.merklePathSide.push_back(true); + } + // This won't verify correctly (wrong hashes), but it won't be rejected for length + // The return value will be false because hashes don't match, not because of DoS limit + // The key is that it processes the path instead of rejecting it immediately + (void)atLimitProof.Verify(MakeTestHash(100), MakeTestHash(200)); +} + +// Additional merkle proof verification tests +BOOST_AUTO_TEST_CASE(merkle_proof_all_leaf_positions) +{ + // Test with 8 leaves to cover more edge cases + std::vector leaves; + for (int i = 0; i < 8; ++i) { + leaves.push_back(MakeTestHash(i + 1)); + } + std::sort(leaves.begin(), leaves.end()); + + // Manually compute the merkle tree + // Level 0: leaf0, leaf1, leaf2, leaf3, leaf4, leaf5, leaf6, leaf7 + // Level 1: h01, h23, h45, h67 + // Level 2: h0123, h4567 + // Level 3: root + uint256 h01 = Hash(leaves[0], leaves[1]); + uint256 h23 = Hash(leaves[2], leaves[3]); + uint256 h45 = Hash(leaves[4], leaves[5]); + uint256 h67 = Hash(leaves[6], leaves[7]); + uint256 h0123 = Hash(h01, h23); + uint256 h4567 = Hash(h45, h67); + uint256 root = Hash(h0123, h4567); + + // Test proof for leaf 0: path = [leaf1, h23, h4567] + { + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaves[1], h23, h4567}; + proof.merklePathSide = {true, true, true}; // all siblings on right + BOOST_CHECK(proof.Verify(leaves[0], root)); + } + + // Test proof for leaf 3: path = [leaf2, h01, h4567] + { + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaves[2], h01, h4567}; + proof.merklePathSide = {false, false, true}; // leaf2 left, h01 left, h4567 right + BOOST_CHECK(proof.Verify(leaves[3], root)); + } + + // Test proof for leaf 7: path = [leaf6, h45, h0123] + { + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaves[6], h45, h0123}; + proof.merklePathSide = {false, false, false}; // all siblings on left + BOOST_CHECK(proof.Verify(leaves[7], root)); + } + + // Test proof for leaf 4: path = [leaf5, h67, h0123] + { + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaves[5], h67, h0123}; + proof.merklePathSide = {true, true, false}; // leaf5 right, h67 right, h0123 left + BOOST_CHECK(proof.Verify(leaves[4], root)); + } +} + +// Test proof for 5 leaves (odd tree) +BOOST_AUTO_TEST_CASE(merkle_proof_five_leaves) +{ + std::vector leaves; + for (int i = 0; i < 5; ++i) { + leaves.push_back(MakeTestHash(i + 1)); + } + std::sort(leaves.begin(), leaves.end()); + + // With 5 leaves: + // Level 0: leaf0, leaf1, leaf2, leaf3, leaf4 + // Level 1: h01, h23, h44 (leaf4 duplicated) + // Level 2: h0123, h4444 (h44 duplicated) + // Level 3: root + uint256 h01 = Hash(leaves[0], leaves[1]); + uint256 h23 = Hash(leaves[2], leaves[3]); + uint256 h44 = Hash(leaves[4], leaves[4]); + uint256 h0123 = Hash(h01, h23); + uint256 h4444 = Hash(h44, h44); + uint256 root = Hash(h0123, h4444); + + // Test proof for leaf 4 (the odd one) + { + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaves[4], h44, h0123}; // self-duplicate at first level + proof.merklePathSide = {true, true, false}; + BOOST_CHECK(proof.Verify(leaves[4], root)); + } + + // Test proof for leaf 2 + { + llmq::QuorumMerkleProof proof; + proof.merklePath = {leaves[3], h01, h4444}; + proof.merklePathSide = {true, false, true}; + BOOST_CHECK(proof.Verify(leaves[2], root)); + } +} + +BOOST_AUTO_TEST_SUITE_END() + +// +// REGRESSION TESTS for security issues identified in code review +// These tests should FAIL before the fix and PASS after +// + +// Use RegTestingSetup for tests that need full node infrastructure +BOOST_FIXTURE_TEST_SUITE(quorum_proofs_regression_tests, RegTestingSetup) + +// Regression test: Forged chainlock signature should be REJECTED +// BUG: VerifyProofChain only checks signature.IsValid() (format), not actual BLS verification +// This test FAILS before the fix (error is NOT about signature), PASSES after (error IS about signature) +BOOST_AUTO_TEST_CASE(forged_chainlock_signature_rejected) +{ + // Skip if llmq_ctx is not available (shouldn't happen in RegTestingSetup) + if (!m_node.llmq_ctx || !m_node.llmq_ctx->quorum_block_processor) { + BOOST_TEST_MESSAGE("Skipping test: LLMQ context not available"); + return; + } + + // Create the proof manager + llmq::CQuorumProofManager proofManager(*m_node.evodb, *m_node.llmq_ctx->quorum_block_processor); + + // Create a legitimate quorum key + CBLSSecretKey legitimateKey; + legitimateKey.MakeNewKey(); + + // Create an ATTACKER's key (different from legitimate) + CBLSSecretKey attackerKey; + attackerKey.MakeNewKey(); + + // Create checkpoint with the LEGITIMATE quorum key + llmq::QuorumCheckpoint checkpoint; + checkpoint.blockHash = uint256::ONE; + checkpoint.height = 99; + + llmq::QuorumCheckpoint::QuorumEntry checkpointQuorum; + checkpointQuorum.quorumHash = uint256::TWO; + checkpointQuorum.quorumType = Consensus::LLMQType::LLMQ_TEST; + checkpointQuorum.publicKey = legitimateKey.GetPublicKey(); + checkpoint.chainlockQuorums.push_back(checkpointQuorum); + + // Create a chainlock signed with ATTACKER's key (not the checkpoint's key) + llmq::ChainlockProofEntry clEntry; + clEntry.nHeight = 100; + clEntry.blockHash = uint256::ONE; + // Sign with attacker's key - this is the forged signature + clEntry.signature = attackerKey.Sign(clEntry.blockHash, /*specificLegacyScheme=*/false); + + // Verify the signature is format-valid but cryptographically invalid + BOOST_CHECK(clEntry.signature.IsValid()); // Format is valid + BOOST_CHECK(!clEntry.signature.VerifyInsecure(legitimateKey.GetPublicKey(), clEntry.blockHash, false)); // But doesn't verify + + // Create minimal proof chain with the forged chainlock + llmq::QuorumProofChain chain; + + // Add a header + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = uint256::ZERO; + header.hashMerkleRoot = uint256::ONE; + header.nTime = 1234567890; + header.nBits = 0x1d00ffff; + header.nNonce = 1; + chain.headers.push_back(header); + + // Add the forged chainlock + chain.chainlocks.push_back(clEntry); + + // Add minimal quorum proof + llmq::QuorumCommitmentProof qProof; + qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST; + qProof.commitment.quorumHash = uint256::TWO; + qProof.chainlockIndex = 0; + + CMutableTransaction mtx; + mtx.nVersion = 3; + mtx.nType = TRANSACTION_COINBASE; + qProof.coinbaseTx = MakeTransactionRef(mtx); + chain.quorumProofs.push_back(qProof); + + // Call VerifyProofChain + auto result = proofManager.VerifyProofChain( + checkpoint, chain, + Consensus::LLMQType::LLMQ_TEST, uint256::TWO); + + // The result should be invalid + BOOST_CHECK(!result.valid); + + // REGRESSION CHECK: The error should mention "signature" because we're testing + // that forged signatures are caught. If the error is about something else + // (like "merkle proof" or "coinbase"), the signature check is not working. + // + // BEFORE FIX: This check FAILS because error is NOT about signature + // AFTER FIX: This check PASSES because error IS about signature + bool errorMentionsSignature = result.error.find("signature") != std::string::npos || + result.error.find("Signature") != std::string::npos; + BOOST_CHECK_MESSAGE(errorMentionsSignature, + "Expected error about signature verification, got: " + result.error); +} + +// Regression test: Discontinuous header chain should be REJECTED +// BUG: VerifyProofChain doesn't validate header chain continuity +// This test FAILS before the fix (error is NOT about headers), PASSES after +BOOST_AUTO_TEST_CASE(discontinuous_headers_rejected) +{ + if (!m_node.llmq_ctx || !m_node.llmq_ctx->quorum_block_processor) { + BOOST_TEST_MESSAGE("Skipping test: LLMQ context not available"); + return; + } + + llmq::CQuorumProofManager proofManager(*m_node.evodb, *m_node.llmq_ctx->quorum_block_processor); + + // Create checkpoint + llmq::QuorumCheckpoint checkpoint; + checkpoint.blockHash = uint256::ONE; + checkpoint.height = 99; + + CBLSSecretKey sk; + sk.MakeNewKey(); + + llmq::QuorumCheckpoint::QuorumEntry checkpointQuorum; + checkpointQuorum.quorumHash = uint256::TWO; + checkpointQuorum.quorumType = Consensus::LLMQType::LLMQ_TEST; + checkpointQuorum.publicKey = sk.GetPublicKey(); + checkpoint.chainlockQuorums.push_back(checkpointQuorum); + + // Create proof chain with DISCONTINUOUS headers + llmq::QuorumProofChain chain; + + CBlockHeader header1; + header1.nVersion = 1; + header1.hashPrevBlock = uint256::ZERO; + header1.hashMerkleRoot = uint256::ONE; + header1.nTime = 1234567890; + header1.nBits = 0x1d00ffff; + header1.nNonce = 1; + + CBlockHeader header2; + header2.nVersion = 1; + // BUG TRIGGER: prevBlockHash does NOT match header1.GetHash() + header2.hashPrevBlock = uint256::TWO; // Should be header1.GetHash() + header2.hashMerkleRoot = uint256::TWO; + header2.nTime = 1234567891; + header2.nBits = 0x1d00ffff; + header2.nNonce = 2; + + chain.headers.push_back(header1); + chain.headers.push_back(header2); + + // Add chainlock + llmq::ChainlockProofEntry clEntry; + clEntry.nHeight = 100; + clEntry.blockHash = header1.GetHash(); + clEntry.signature = sk.Sign(clEntry.blockHash, false); + chain.chainlocks.push_back(clEntry); + + // Add quorum proof + llmq::QuorumCommitmentProof qProof; + qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST; + qProof.commitment.quorumHash = uint256::TWO; + qProof.chainlockIndex = 0; + + CMutableTransaction mtx; + mtx.nVersion = 3; + mtx.nType = TRANSACTION_COINBASE; + qProof.coinbaseTx = MakeTransactionRef(mtx); + chain.quorumProofs.push_back(qProof); + + auto result = proofManager.VerifyProofChain( + checkpoint, chain, + Consensus::LLMQType::LLMQ_TEST, uint256::TWO); + + BOOST_CHECK(!result.valid); + + // REGRESSION CHECK: Error should mention "header" or "continuous" or "chain" + // BEFORE FIX: This FAILS because error is about something else + // AFTER FIX: This PASSES because error is about header continuity + bool errorMentionsHeaders = result.error.find("header") != std::string::npos || + result.error.find("Header") != std::string::npos || + result.error.find("continuous") != std::string::npos || + result.error.find("chain") != std::string::npos; + BOOST_CHECK_MESSAGE(errorMentionsHeaders, + "Expected error about header chain continuity, got: " + result.error); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py new file mode 100644 index 000000000000..09cc0ff2b4a9 --- /dev/null +++ b/test/functional/feature_quorum_proof_chain.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +""" +feature_quorum_proof_chain.py + +Tests trustless quorum proof chain generation and verification. +""" + +from test_framework.test_framework import DashTestFramework +from test_framework.util import assert_equal, assert_raises_rpc_error + + +class QuorumProofChainTest(DashTestFramework): + def set_test_params(self): + self.set_dash_test_params(5, 4) + self.delay_v20_and_mn_rr(height=200) + + def run_test(self): + # Connect all nodes to node1 so that we always have the whole network connected + # Otherwise only masternode connections will be established between nodes + for i in range(2, len(self.nodes)): + self.connect_nodes(i, 1) + + self.activate_v20(expected_activation_height=200) + self.log.info("Activated v20 at height:" + str(self.nodes[0].getblockcount())) + + # Enable quorum DKG + self.nodes[0].sporkupdate("SPORK_17_QUORUM_DKG_ENABLED", 0) + self.wait_for_sporks_same() + + # Mine quorums and wait for chainlocks + self.log.info("Mining quorum cycle...") + self.mine_cycle_quorum() + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + + # Mine additional blocks to ensure chainlocks are indexed + self.log.info("Mining additional blocks...") + self.generate(self.nodes[0], 10, sync_fun=self.sync_blocks) + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + + # Run tests + self.test_chainlock_index() + self.test_getchainlockbyheight() + self.test_getchainlockbyheight_errors() + + def test_chainlock_index(self): + """Verify chainlocks are indexed from cbtx on block connect.""" + self.log.info("Testing chainlock indexing...") + + tip_height = self.nodes[0].getblockcount() + + # Find a chainlocked height + for h in range(tip_height, 200, -1): + try: + cl_info = self.nodes[0].getchainlockbyheight(h) + self.log.info(f"Found chainlock at height {h}") + + # Verify the structure + assert 'height' in cl_info + assert 'blockhash' in cl_info + assert 'signature' in cl_info + assert 'cbtx_height' in cl_info + + assert_equal(cl_info['height'], h) + + # Verify blockhash matches + block_hash = self.nodes[0].getblockhash(h) + assert_equal(cl_info['blockhash'], block_hash) + + self.log.info("Chainlock index verified successfully") + return + except Exception: + continue + + self.log.info("No chainlocks found in index (may be expected in early blocks)") + + def test_getchainlockbyheight(self): + """Test getchainlockbyheight RPC.""" + self.log.info("Testing getchainlockbyheight...") + + tip_height = self.nodes[0].getblockcount() + + # Try to find a valid chainlocked height + found = False + for h in range(tip_height, 200, -1): + try: + result = self.nodes[0].getchainlockbyheight(h) + assert_equal(result['height'], h) + found = True + break + except Exception: + continue + + if found: + self.log.info("getchainlockbyheight working correctly") + else: + self.log.info("No chainlocks available yet") + + def test_getchainlockbyheight_errors(self): + """Test getchainlockbyheight error handling.""" + self.log.info("Testing getchainlockbyheight errors...") + + # Future height should fail + tip_height = self.nodes[0].getblockcount() + assert_raises_rpc_error(-5, "Chainlock not found", + self.nodes[0].getchainlockbyheight, tip_height + 1000) + + # Negative height should fail + assert_raises_rpc_error(-8, "height must be non-negative", + self.nodes[0].getchainlockbyheight, -1) + + def build_checkpoint(self): + """Build checkpoint from current chain state.""" + # Get current chainlock quorums + # LLMQ_TEST type for regtest chainlocks + llmq_type = 104 + try: + cl_quorums = self.nodes[0].quorum("list", llmq_type) + except Exception: + # If quorum list fails, try with different type + cl_quorums = [] + + quorum_entries = [] + for qhash in cl_quorums: + try: + info = self.nodes[0].quorum("info", llmq_type, qhash) + quorum_entries.append({ + 'quorum_hash': qhash, + 'quorum_type': llmq_type, + 'public_key': info['quorumPublicKey'] + }) + except Exception: + continue + + return { + 'block_hash': self.nodes[0].getbestblockhash(), + 'height': self.nodes[0].getblockcount(), + 'chainlock_quorums': quorum_entries + } + + +if __name__ == '__main__': + QuorumProofChainTest().main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index fcbc405d2d9b..9bd5e887d17e 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -125,6 +125,7 @@ 'feature_llmq_connections.py', # NOTE: needs dash_hash to pass 'feature_llmq_is_retroactive.py', # NOTE: needs dash_hash to pass 'feature_llmq_chainlocks.py', # NOTE: needs dash_hash to pass + 'feature_quorum_proof_chain.py', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose.py', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose.py --disable-spork23', # NOTE: needs dash_hash to pass 'feature_dip3_deterministicmns.py --legacy-wallet', # NOTE: needs dash_hash to pass From 1c3c413ff2ef98069c05207848d3254145af273c Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 09:07:44 -0600 Subject: [PATCH 02/12] fix(llmq): address CI failures for quorum proof chain tests - Add trivial test case to quorum_proofs_tests to ensure suite has at least one test in all build configurations (fixes nowallet build) - Replace std::to_string with strprintf to avoid locale-dependent functions (fixes lint-locale-dependence) - Move regression tests to separate file quorum_proofs_regression_tests.cpp to comply with one-suite-per-file naming convention (fixes lint-tests) - Set executable permission on feature_quorum_proof_chain.py functional test (fixes lint-files) Co-Authored-By: Claude Opus 4.5 --- src/Makefile.test.include | 1 + src/llmq/quorumproofs.cpp | 19 +- src/test/quorum_proofs_regression_tests.cpp | 206 ++++++++++++++++++ src/test/quorum_proofs_tests.cpp | 191 +--------------- test/functional/feature_quorum_proof_chain.py | 0 5 files changed, 220 insertions(+), 197 deletions(-) create mode 100644 src/test/quorum_proofs_regression_tests.cpp mode change 100644 => 100755 test/functional/feature_quorum_proof_chain.py diff --git a/src/Makefile.test.include b/src/Makefile.test.include index c0e387fc28e3..1b21fb050a14 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -141,6 +141,7 @@ BITCOIN_TESTS =\ test/llmq_utils_tests.cpp \ test/logging_tests.cpp \ test/quorum_proofs_tests.cpp \ + test/quorum_proofs_regression_tests.cpp \ test/dbwrapper_tests.cpp \ test/validation_tests.cpp \ test/mempool_tests.cpp \ diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index 3acb8373a148..e286ff4b018c 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -582,7 +583,7 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( // This prevents an attacker from mixing headers from different blockchain forks for (size_t i = 1; i < proof.headers.size(); ++i) { if (proof.headers[i].hashPrevBlock != proof.headers[i - 1].GetHash()) { - result.error = "Header chain is not continuous - prevBlockHash mismatch at index " + std::to_string(i); + result.error = strprintf("Header chain is not continuous - prevBlockHash mismatch at index %d", i); return result; } } @@ -605,7 +606,7 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( // Get the chainlock that covers this commitment if (qProof.chainlockIndex >= proof.chainlocks.size()) { - result.error = "Invalid chainlock index " + std::to_string(qProof.chainlockIndex); + result.error = strprintf("Invalid chainlock index %d", qProof.chainlockIndex); return result; } const auto& chainlock = proof.chainlocks[qProof.chainlockIndex]; @@ -613,7 +614,7 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( // Verify chainlock signature if we haven't verified this chainlock yet if (!verifiedChainlockHeights.count(chainlock.nHeight)) { if (!chainlock.signature.IsValid()) { - result.error = "Invalid chainlock signature format at height " + std::to_string(chainlock.nHeight); + result.error = strprintf("Invalid chainlock signature format at height %d", chainlock.nHeight); return result; } @@ -628,9 +629,7 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( const bool signatureVerified = std::any_of(knownQuorumPubKeys.begin(), knownQuorumPubKeys.end(), verifyAgainstKey); if (!signatureVerified) { - result.error = "Chainlock signature verification failed at height " + - std::to_string(chainlock.nHeight) + - " - signature does not match any known quorum key"; + result.error = strprintf("Chainlock signature verification failed at height %d - signature does not match any known quorum key", chainlock.nHeight); return result; } @@ -642,21 +641,21 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( // Verify coinbase tx is in the block via merkle proof if (!qProof.coinbaseTx) { - result.error = "Missing coinbase transaction in proof " + std::to_string(proofIdx); + result.error = strprintf("Missing coinbase transaction in proof %d", proofIdx); return result; } const uint256 coinbaseTxHash = qProof.coinbaseTx->GetHash(); if (!VerifyMerkleProof(coinbaseTxHash, qProof.coinbaseMerklePath, qProof.coinbaseMerklePathSide, header.hashMerkleRoot)) { - result.error = "Coinbase merkle proof verification failed in proof " + std::to_string(proofIdx); + result.error = strprintf("Coinbase merkle proof verification failed in proof %d", proofIdx); return result; } // Extract merkleRootQuorums from cbtx auto opt_cbtx = GetTxPayload(*qProof.coinbaseTx); if (!opt_cbtx.has_value()) { - result.error = "Invalid coinbase transaction payload in proof " + std::to_string(proofIdx); + result.error = strprintf("Invalid coinbase transaction payload in proof %d", proofIdx); return result; } @@ -665,7 +664,7 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( // Verify the quorum commitment merkle proof against merkleRootQuorums uint256 commitmentHash = ::SerializeHash(qProof.commitment); if (!qProof.quorumMerkleProof.Verify(commitmentHash, cbtx.merkleRootQuorums)) { - result.error = "Quorum commitment merkle proof verification failed in proof " + std::to_string(proofIdx); + result.error = strprintf("Quorum commitment merkle proof verification failed in proof %d", proofIdx); return result; } diff --git a/src/test/quorum_proofs_regression_tests.cpp b/src/test/quorum_proofs_regression_tests.cpp new file mode 100644 index 000000000000..49960f3645af --- /dev/null +++ b/src/test/quorum_proofs_regression_tests.cpp @@ -0,0 +1,206 @@ +// Copyright (c) 2025 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +// +// REGRESSION TESTS for security issues identified in code review +// These tests should FAIL before the fix and PASS after +// + +// Use RegTestingSetup for tests that need full node infrastructure +BOOST_FIXTURE_TEST_SUITE(quorum_proofs_regression_tests, RegTestingSetup) + +// Trivial test case to ensure the suite always has at least one test case +// even in builds where some functionality may not be available +BOOST_AUTO_TEST_CASE(trivially_passes) { BOOST_CHECK(true); } + +// Regression test: Forged chainlock signature should be REJECTED +// BUG: VerifyProofChain only checks signature.IsValid() (format), not actual BLS verification +// This test FAILS before the fix (error is NOT about signature), PASSES after (error IS about signature) +BOOST_AUTO_TEST_CASE(forged_chainlock_signature_rejected) +{ + // Skip if llmq_ctx is not available (shouldn't happen in RegTestingSetup) + if (!m_node.llmq_ctx || !m_node.llmq_ctx->quorum_block_processor) { + BOOST_TEST_MESSAGE("Skipping test: LLMQ context not available"); + return; + } + + // Create the proof manager + llmq::CQuorumProofManager proofManager(*m_node.evodb, *m_node.llmq_ctx->quorum_block_processor); + + // Create a legitimate quorum key + CBLSSecretKey legitimateKey; + legitimateKey.MakeNewKey(); + + // Create an ATTACKER's key (different from legitimate) + CBLSSecretKey attackerKey; + attackerKey.MakeNewKey(); + + // Create checkpoint with the LEGITIMATE quorum key + llmq::QuorumCheckpoint checkpoint; + checkpoint.blockHash = uint256::ONE; + checkpoint.height = 99; + + llmq::QuorumCheckpoint::QuorumEntry checkpointQuorum; + checkpointQuorum.quorumHash = uint256::TWO; + checkpointQuorum.quorumType = Consensus::LLMQType::LLMQ_TEST; + checkpointQuorum.publicKey = legitimateKey.GetPublicKey(); + checkpoint.chainlockQuorums.push_back(checkpointQuorum); + + // Create a chainlock signed with ATTACKER's key (not the checkpoint's key) + llmq::ChainlockProofEntry clEntry; + clEntry.nHeight = 100; + clEntry.blockHash = uint256::ONE; + // Sign with attacker's key - this is the forged signature + clEntry.signature = attackerKey.Sign(clEntry.blockHash, /*specificLegacyScheme=*/false); + + // Verify the signature is format-valid but cryptographically invalid + BOOST_CHECK(clEntry.signature.IsValid()); // Format is valid + BOOST_CHECK(!clEntry.signature.VerifyInsecure(legitimateKey.GetPublicKey(), clEntry.blockHash, false)); // But doesn't verify + + // Create minimal proof chain with the forged chainlock + llmq::QuorumProofChain chain; + + // Add a header + CBlockHeader header; + header.nVersion = 1; + header.hashPrevBlock = uint256::ZERO; + header.hashMerkleRoot = uint256::ONE; + header.nTime = 1234567890; + header.nBits = 0x1d00ffff; + header.nNonce = 1; + chain.headers.push_back(header); + + // Add the forged chainlock + chain.chainlocks.push_back(clEntry); + + // Add minimal quorum proof + llmq::QuorumCommitmentProof qProof; + qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST; + qProof.commitment.quorumHash = uint256::TWO; + qProof.chainlockIndex = 0; + + CMutableTransaction mtx; + mtx.nVersion = 3; + mtx.nType = TRANSACTION_COINBASE; + qProof.coinbaseTx = MakeTransactionRef(mtx); + chain.quorumProofs.push_back(qProof); + + // Call VerifyProofChain + auto result = proofManager.VerifyProofChain( + checkpoint, chain, + Consensus::LLMQType::LLMQ_TEST, uint256::TWO); + + // The result should be invalid + BOOST_CHECK(!result.valid); + + // REGRESSION CHECK: The error should mention "signature" because we're testing + // that forged signatures are caught. If the error is about something else + // (like "merkle proof" or "coinbase"), the signature check is not working. + // + // BEFORE FIX: This check FAILS because error is NOT about signature + // AFTER FIX: This check PASSES because error IS about signature + bool errorMentionsSignature = result.error.find("signature") != std::string::npos || + result.error.find("Signature") != std::string::npos; + BOOST_CHECK_MESSAGE(errorMentionsSignature, + "Expected error about signature verification, got: " + result.error); +} + +// Regression test: Discontinuous header chain should be REJECTED +// BUG: VerifyProofChain doesn't validate header chain continuity +// This test FAILS before the fix (error is NOT about headers), PASSES after +BOOST_AUTO_TEST_CASE(discontinuous_headers_rejected) +{ + if (!m_node.llmq_ctx || !m_node.llmq_ctx->quorum_block_processor) { + BOOST_TEST_MESSAGE("Skipping test: LLMQ context not available"); + return; + } + + llmq::CQuorumProofManager proofManager(*m_node.evodb, *m_node.llmq_ctx->quorum_block_processor); + + // Create checkpoint + llmq::QuorumCheckpoint checkpoint; + checkpoint.blockHash = uint256::ONE; + checkpoint.height = 99; + + CBLSSecretKey sk; + sk.MakeNewKey(); + + llmq::QuorumCheckpoint::QuorumEntry checkpointQuorum; + checkpointQuorum.quorumHash = uint256::TWO; + checkpointQuorum.quorumType = Consensus::LLMQType::LLMQ_TEST; + checkpointQuorum.publicKey = sk.GetPublicKey(); + checkpoint.chainlockQuorums.push_back(checkpointQuorum); + + // Create proof chain with DISCONTINUOUS headers + llmq::QuorumProofChain chain; + + CBlockHeader header1; + header1.nVersion = 1; + header1.hashPrevBlock = uint256::ZERO; + header1.hashMerkleRoot = uint256::ONE; + header1.nTime = 1234567890; + header1.nBits = 0x1d00ffff; + header1.nNonce = 1; + + CBlockHeader header2; + header2.nVersion = 1; + // BUG TRIGGER: prevBlockHash does NOT match header1.GetHash() + header2.hashPrevBlock = uint256::TWO; // Should be header1.GetHash() + header2.hashMerkleRoot = uint256::TWO; + header2.nTime = 1234567891; + header2.nBits = 0x1d00ffff; + header2.nNonce = 2; + + chain.headers.push_back(header1); + chain.headers.push_back(header2); + + // Add chainlock + llmq::ChainlockProofEntry clEntry; + clEntry.nHeight = 100; + clEntry.blockHash = header1.GetHash(); + clEntry.signature = sk.Sign(clEntry.blockHash, false); + chain.chainlocks.push_back(clEntry); + + // Add quorum proof + llmq::QuorumCommitmentProof qProof; + qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST; + qProof.commitment.quorumHash = uint256::TWO; + qProof.chainlockIndex = 0; + + CMutableTransaction mtx; + mtx.nVersion = 3; + mtx.nType = TRANSACTION_COINBASE; + qProof.coinbaseTx = MakeTransactionRef(mtx); + chain.quorumProofs.push_back(qProof); + + auto result = proofManager.VerifyProofChain( + checkpoint, chain, + Consensus::LLMQType::LLMQ_TEST, uint256::TWO); + + BOOST_CHECK(!result.valid); + + // REGRESSION CHECK: Error should mention "header" or "continuous" or "chain" + // BEFORE FIX: This FAILS because error is about something else + // AFTER FIX: This PASSES because error is about header continuity + bool errorMentionsHeaders = result.error.find("header") != std::string::npos || + result.error.find("Header") != std::string::npos || + result.error.find("continuous") != std::string::npos || + result.error.find("chain") != std::string::npos; + BOOST_CHECK_MESSAGE(errorMentionsHeaders, + "Expected error about header chain continuity, got: " + result.error); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/quorum_proofs_tests.cpp b/src/test/quorum_proofs_tests.cpp index 579e9faf96fe..73cd8250779a 100644 --- a/src/test/quorum_proofs_tests.cpp +++ b/src/test/quorum_proofs_tests.cpp @@ -22,6 +22,10 @@ BOOST_FIXTURE_TEST_SUITE(quorum_proofs_tests, BasicTestingSetup) +// Trivial test case to ensure the suite always has at least one test case +// even in builds where some functionality may not be available +BOOST_AUTO_TEST_CASE(trivially_passes) { BOOST_CHECK(true); } + // Helper function to create test hashes static uint256 MakeTestHash(int n) { @@ -383,190 +387,3 @@ BOOST_AUTO_TEST_CASE(merkle_proof_five_leaves) } BOOST_AUTO_TEST_SUITE_END() - -// -// REGRESSION TESTS for security issues identified in code review -// These tests should FAIL before the fix and PASS after -// - -// Use RegTestingSetup for tests that need full node infrastructure -BOOST_FIXTURE_TEST_SUITE(quorum_proofs_regression_tests, RegTestingSetup) - -// Regression test: Forged chainlock signature should be REJECTED -// BUG: VerifyProofChain only checks signature.IsValid() (format), not actual BLS verification -// This test FAILS before the fix (error is NOT about signature), PASSES after (error IS about signature) -BOOST_AUTO_TEST_CASE(forged_chainlock_signature_rejected) -{ - // Skip if llmq_ctx is not available (shouldn't happen in RegTestingSetup) - if (!m_node.llmq_ctx || !m_node.llmq_ctx->quorum_block_processor) { - BOOST_TEST_MESSAGE("Skipping test: LLMQ context not available"); - return; - } - - // Create the proof manager - llmq::CQuorumProofManager proofManager(*m_node.evodb, *m_node.llmq_ctx->quorum_block_processor); - - // Create a legitimate quorum key - CBLSSecretKey legitimateKey; - legitimateKey.MakeNewKey(); - - // Create an ATTACKER's key (different from legitimate) - CBLSSecretKey attackerKey; - attackerKey.MakeNewKey(); - - // Create checkpoint with the LEGITIMATE quorum key - llmq::QuorumCheckpoint checkpoint; - checkpoint.blockHash = uint256::ONE; - checkpoint.height = 99; - - llmq::QuorumCheckpoint::QuorumEntry checkpointQuorum; - checkpointQuorum.quorumHash = uint256::TWO; - checkpointQuorum.quorumType = Consensus::LLMQType::LLMQ_TEST; - checkpointQuorum.publicKey = legitimateKey.GetPublicKey(); - checkpoint.chainlockQuorums.push_back(checkpointQuorum); - - // Create a chainlock signed with ATTACKER's key (not the checkpoint's key) - llmq::ChainlockProofEntry clEntry; - clEntry.nHeight = 100; - clEntry.blockHash = uint256::ONE; - // Sign with attacker's key - this is the forged signature - clEntry.signature = attackerKey.Sign(clEntry.blockHash, /*specificLegacyScheme=*/false); - - // Verify the signature is format-valid but cryptographically invalid - BOOST_CHECK(clEntry.signature.IsValid()); // Format is valid - BOOST_CHECK(!clEntry.signature.VerifyInsecure(legitimateKey.GetPublicKey(), clEntry.blockHash, false)); // But doesn't verify - - // Create minimal proof chain with the forged chainlock - llmq::QuorumProofChain chain; - - // Add a header - CBlockHeader header; - header.nVersion = 1; - header.hashPrevBlock = uint256::ZERO; - header.hashMerkleRoot = uint256::ONE; - header.nTime = 1234567890; - header.nBits = 0x1d00ffff; - header.nNonce = 1; - chain.headers.push_back(header); - - // Add the forged chainlock - chain.chainlocks.push_back(clEntry); - - // Add minimal quorum proof - llmq::QuorumCommitmentProof qProof; - qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST; - qProof.commitment.quorumHash = uint256::TWO; - qProof.chainlockIndex = 0; - - CMutableTransaction mtx; - mtx.nVersion = 3; - mtx.nType = TRANSACTION_COINBASE; - qProof.coinbaseTx = MakeTransactionRef(mtx); - chain.quorumProofs.push_back(qProof); - - // Call VerifyProofChain - auto result = proofManager.VerifyProofChain( - checkpoint, chain, - Consensus::LLMQType::LLMQ_TEST, uint256::TWO); - - // The result should be invalid - BOOST_CHECK(!result.valid); - - // REGRESSION CHECK: The error should mention "signature" because we're testing - // that forged signatures are caught. If the error is about something else - // (like "merkle proof" or "coinbase"), the signature check is not working. - // - // BEFORE FIX: This check FAILS because error is NOT about signature - // AFTER FIX: This check PASSES because error IS about signature - bool errorMentionsSignature = result.error.find("signature") != std::string::npos || - result.error.find("Signature") != std::string::npos; - BOOST_CHECK_MESSAGE(errorMentionsSignature, - "Expected error about signature verification, got: " + result.error); -} - -// Regression test: Discontinuous header chain should be REJECTED -// BUG: VerifyProofChain doesn't validate header chain continuity -// This test FAILS before the fix (error is NOT about headers), PASSES after -BOOST_AUTO_TEST_CASE(discontinuous_headers_rejected) -{ - if (!m_node.llmq_ctx || !m_node.llmq_ctx->quorum_block_processor) { - BOOST_TEST_MESSAGE("Skipping test: LLMQ context not available"); - return; - } - - llmq::CQuorumProofManager proofManager(*m_node.evodb, *m_node.llmq_ctx->quorum_block_processor); - - // Create checkpoint - llmq::QuorumCheckpoint checkpoint; - checkpoint.blockHash = uint256::ONE; - checkpoint.height = 99; - - CBLSSecretKey sk; - sk.MakeNewKey(); - - llmq::QuorumCheckpoint::QuorumEntry checkpointQuorum; - checkpointQuorum.quorumHash = uint256::TWO; - checkpointQuorum.quorumType = Consensus::LLMQType::LLMQ_TEST; - checkpointQuorum.publicKey = sk.GetPublicKey(); - checkpoint.chainlockQuorums.push_back(checkpointQuorum); - - // Create proof chain with DISCONTINUOUS headers - llmq::QuorumProofChain chain; - - CBlockHeader header1; - header1.nVersion = 1; - header1.hashPrevBlock = uint256::ZERO; - header1.hashMerkleRoot = uint256::ONE; - header1.nTime = 1234567890; - header1.nBits = 0x1d00ffff; - header1.nNonce = 1; - - CBlockHeader header2; - header2.nVersion = 1; - // BUG TRIGGER: prevBlockHash does NOT match header1.GetHash() - header2.hashPrevBlock = uint256::TWO; // Should be header1.GetHash() - header2.hashMerkleRoot = uint256::TWO; - header2.nTime = 1234567891; - header2.nBits = 0x1d00ffff; - header2.nNonce = 2; - - chain.headers.push_back(header1); - chain.headers.push_back(header2); - - // Add chainlock - llmq::ChainlockProofEntry clEntry; - clEntry.nHeight = 100; - clEntry.blockHash = header1.GetHash(); - clEntry.signature = sk.Sign(clEntry.blockHash, false); - chain.chainlocks.push_back(clEntry); - - // Add quorum proof - llmq::QuorumCommitmentProof qProof; - qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST; - qProof.commitment.quorumHash = uint256::TWO; - qProof.chainlockIndex = 0; - - CMutableTransaction mtx; - mtx.nVersion = 3; - mtx.nType = TRANSACTION_COINBASE; - qProof.coinbaseTx = MakeTransactionRef(mtx); - chain.quorumProofs.push_back(qProof); - - auto result = proofManager.VerifyProofChain( - checkpoint, chain, - Consensus::LLMQType::LLMQ_TEST, uint256::TWO); - - BOOST_CHECK(!result.valid); - - // REGRESSION CHECK: Error should mention "header" or "continuous" or "chain" - // BEFORE FIX: This FAILS because error is about something else - // AFTER FIX: This PASSES because error is about header continuity - bool errorMentionsHeaders = result.error.find("header") != std::string::npos || - result.error.find("Header") != std::string::npos || - result.error.find("continuous") != std::string::npos || - result.error.find("chain") != std::string::npos; - BOOST_CHECK_MESSAGE(errorMentionsHeaders, - "Expected error about header chain continuity, got: " + result.error); -} - -BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py old mode 100644 new mode 100755 From 62e6357bf9a231fcd0475ea50015121802438715 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 11:38:22 -0600 Subject: [PATCH 03/12] fix(llmq): fix quorum proof chain algorithm for long proofs Key fixes: - Use mined block (minedBlockHash) instead of formation block (m_quorum_base_block_index) when looking up commitment proofs - Fix BuildQuorumMerkleProof to match CalcCbTxMerkleRootQuorums logic by using pindex->pprev and scanning current block transactions - Fix GetAncestor issue by using active_chain[] for chainlock blocks since chainlock height can be >= mined block height - Add FindChainlockSignedByKnownQuorum for direct path optimization to reduce proof chain length when checkpoint quorum is still active - Add MigrateChainlockIndex to build chainlock index from historical blocks on first run after upgrade - Increase MAX_PROOF_CHAIN_LENGTH from 50 to 500 for long proofs - Add comprehensive debug logging for troubleshooting Benchmark results (single checkpoint quorum): - 30 hours: 1 step, 1.3 KB, 0.14s - 7 days: 5 steps, 6.8 KB, 0.34s - 30 days: 26 steps, 36 KB, 1.4s - 6 months: 150 steps, 208 KB, 14s - 12 months: 292 steps, 402 KB, 26s Co-Authored-By: Claude Opus 4.5 --- src/init.cpp | 7 ++ src/llmq/quorumproofs.cpp | 242 +++++++++++++++++++++++++++++++++++--- src/llmq/quorumproofs.h | 30 ++++- src/rpc/client.cpp | 4 + src/rpc/quorums.cpp | 2 +- 5 files changed, 268 insertions(+), 17 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index 312b38486423..bf976a709d83 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -99,6 +99,7 @@ #include #include #include +#include #include #include #include @@ -2171,6 +2172,12 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) ChainstateManager& chainman = *Assert(node.chainman); + // Migrate chainlock index for quorum proof generation (one-time on first run after upgrade) + if (node.llmq_ctx && node.llmq_ctx->quorum_proof_manager) { + LOCK(cs_main); + node.llmq_ctx->quorum_proof_manager->MigrateChainlockIndex(chainman.ActiveChain(), chainparams); + } + assert(!node.dstxman); node.dstxman = std::make_unique(); diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index e286ff4b018c..b1482013f882 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -13,11 +13,14 @@ #include #include #include +#include #include #include #include #include +#include #include +#include #include #include @@ -316,12 +319,13 @@ std::optional CQuorumProofManager::BuildQuorumMerkleProof( Consensus::LLMQType llmqType, const uint256& quorumHash) const { - if (pindex == nullptr) { + if (pindex == nullptr || pindex->pprev == nullptr) { return std::nullopt; } - // Get all active commitments at this block - auto commitmentsMap = m_quorum_block_processor.GetMinedAndActiveCommitmentsUntilBlock(pindex); + // Get all active commitments UNTIL the previous block (matching CalcCbTxMerkleRootQuorums logic) + // CalcCbTxMerkleRootQuorums uses pindexPrev, then adds commitments from the current block + auto commitmentsMap = m_quorum_block_processor.GetMinedAndActiveCommitmentsUntilBlock(pindex->pprev); // Collect all commitment hashes (matching CalcCbTxMerkleRootQuorums logic) std::vector commitmentHashes; @@ -345,6 +349,31 @@ std::optional CQuorumProofManager::BuildQuorumMerkleProof( } } + // Now add commitments from the current block's transactions (matching CalcCbTxMerkleRootQuorums logic) + // This is necessary because GetMinedAndActiveCommitmentsUntilBlock uses pindexPrev + CBlock block; + if (!ReadBlockFromDisk(block, pindex, Params().GetConsensus())) { + return std::nullopt; + } + + for (size_t i = 1; i < block.vtx.size(); ++i) { + const auto& tx = block.vtx[i]; + if (tx->IsSpecialTxVersion() && tx->nType == TRANSACTION_QUORUM_COMMITMENT) { + const auto opt_qc = GetTxPayload(*tx); + if (!opt_qc || opt_qc->commitment.IsNull()) { + continue; + } + + uint256 commitmentHash = ::SerializeHash(opt_qc->commitment); + commitmentHashes.push_back(commitmentHash); + + if (opt_qc->commitment.llmqType == llmqType && opt_qc->commitment.quorumHash == quorumHash) { + targetCommitmentHash = commitmentHash; + targetFound = true; + } + } + } + if (!targetFound) { return std::nullopt; } @@ -387,6 +416,37 @@ int32_t CQuorumProofManager::FindChainlockCoveringBlock(const CBlockIndex* pMine return -1; } +int32_t CQuorumProofManager::FindChainlockSignedByKnownQuorum( + const CBlockIndex* pMinedBlock, + const std::set& knownQuorumPubKeys, + const CChain& active_chain, + const CQuorumManager& qman) const +{ + if (pMinedBlock == nullptr) { + return -1; + } + + // Search for a chainlock that covers this block AND is signed by a known quorum + // This is more efficient than taking any chainlock and hoping its signer is known + // With 4 active quorums and pseudo-random selection, we expect to find a match + // within ~4 chainlocks on average + const int32_t maxHeight = pMinedBlock->nHeight + MAX_CHAINLOCK_SEARCH_OFFSET; + for (int32_t height = pMinedBlock->nHeight; height <= maxHeight; ++height) { + if (!GetChainlockByHeight(height).has_value()) { + continue; + } + + // Check if the signing quorum for this chainlock height is in our known set + CQuorumCPtr signingQuorum = DetermineChainlockSigningQuorum(height, active_chain, qman); + if (signingQuorum && knownQuorumPubKeys.count(signingQuorum->qc->quorumPublicKey)) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Found chainlock at height %d signed by known quorum %s\n", + __func__, height, signingQuorum->qc->quorumHash.ToString()); + return height; + } + } + return -1; +} + CQuorumCPtr CQuorumProofManager::DetermineChainlockSigningQuorum( int32_t chainlockHeight, const CChain& active_chain, @@ -413,19 +473,28 @@ std::optional CQuorumProofManager::BuildProofChain( Consensus::LLMQType targetQuorumType, const uint256& targetQuorumHash, const CQuorumManager& qman, - const CChain& active_chain) const + const CChain& active_chain, + const node::BlockManager& block_man) const { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Building proof chain for quorum type=%d hash=%s\n", + __func__, static_cast(targetQuorumType), targetQuorumHash.ToString()); + // Phase 1: Build set of known chainlock quorum public keys from checkpoint std::set knownQuorumPubKeys; for (const auto& q : checkpoint.chainlockQuorums) { knownQuorumPubKeys.insert(q.publicKey); + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Checkpoint quorum: hash=%s type=%d pubkey=%s\n", + __func__, q.quorumHash.ToString(), static_cast(q.quorumType), q.publicKey.ToString().substr(0, 32) + "..."); } + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Checkpoint has %d known quorum public keys\n", + __func__, knownQuorumPubKeys.size()); // Phase 2: Work backwards from target to find the dependency chain // Each ProofStep represents a quorum that needs to be proven and // the chainlock height that covers its mined block struct ProofStep { CQuorumCPtr quorum; + const CBlockIndex* pMinedBlockIndex; // Block where commitment was actually mined int32_t chainlockHeight; }; std::vector proofSteps; @@ -434,8 +503,11 @@ std::optional CQuorumProofManager::BuildProofChain( // Start with the target quorum CQuorumCPtr currentQuorum = qman.GetQuorum(targetQuorumType, targetQuorumHash); if (!currentQuorum) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Target quorum not found in quorum manager\n", __func__); return std::nullopt; } + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Found target quorum, formed at height %d\n", + __func__, currentQuorum->m_quorum_base_block_index ? currentQuorum->m_quorum_base_block_index->nHeight : -1); while (true) { // Cycle detection @@ -446,34 +518,75 @@ std::optional CQuorumProofManager::BuildProofChain( // DoS protection: limit chain length if (proofSteps.size() >= MAX_PROOF_CHAIN_LENGTH) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Proof chain length limit reached (%d steps) without finding checkpoint quorum\n", + __func__, MAX_PROOF_CHAIN_LENGTH); return std::nullopt; } - // Find the first chainlock that covers this quorum's mined block - const CBlockIndex* pMinedBlock = currentQuorum->m_quorum_base_block_index; + // Look up the block where the commitment was actually mined + // This is different from the quorum's base block (where the quorum was formed) + const CBlockIndex* pMinedBlock = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(currentQuorum->minedBlockHash)); if (!pMinedBlock) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not find mined block %s for quorum %s\n", + __func__, currentQuorum->minedBlockHash.ToString(), currentQuorum->qc->quorumHash.ToString()); return std::nullopt; } + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Quorum %s: formed at height %d, commitment mined at height %d\n", + __func__, currentQuorum->qc->quorumHash.ToString(), + currentQuorum->m_quorum_base_block_index ? currentQuorum->m_quorum_base_block_index->nHeight : -1, + pMinedBlock->nHeight); + + // First, try to find a chainlock signed by a KNOWN quorum (direct path) + // This is the key optimization: instead of taking any chainlock and hoping + // its signer is known, we actively search for one signed by a known quorum + int32_t chainlockHeight = FindChainlockSignedByKnownQuorum(pMinedBlock, knownQuorumPubKeys, active_chain, qman); + + if (chainlockHeight >= 0) { + // Found a direct path! The signing quorum is already known + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Found DIRECT path via chainlock at height %d\n", + __func__, chainlockHeight); + proofSteps.push_back({currentQuorum, pMinedBlock, chainlockHeight}); + break; // Chain complete! + } - int32_t chainlockHeight = FindChainlockCoveringBlock(pMinedBlock); + // No direct path found - fall back to finding any chainlock + // and adding the signing quorum to the proof chain + chainlockHeight = FindChainlockCoveringBlock(pMinedBlock); if (chainlockHeight < 0) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No chainlock found covering block at height %d (searched up to %d)\n", + __func__, pMinedBlock->nHeight, pMinedBlock->nHeight + MAX_CHAINLOCK_SEARCH_OFFSET); return std::nullopt; // No chainlock found covering this quorum } + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No direct path, using chainlock at height %d\n", + __func__, chainlockHeight); - proofSteps.push_back({currentQuorum, chainlockHeight}); + proofSteps.push_back({currentQuorum, pMinedBlock, chainlockHeight}); // Determine which quorum signed this chainlock CQuorumCPtr signingQuorum = DetermineChainlockSigningQuorum(chainlockHeight, active_chain, qman); if (!signingQuorum) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not determine signing quorum for chainlock at height %d\n", + __func__, chainlockHeight); return std::nullopt; // Could not determine signing quorum } + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Chainlock signed by quorum %s (type %d) pubkey=%s\n", + __func__, signingQuorum->qc->quorumHash.ToString(), static_cast(signingQuorum->qc->llmqType), + signingQuorum->qc->quorumPublicKey.ToString().substr(0, 32) + "..."); + + // The signing quorum is NOT in the checkpoint (we already checked via FindChainlockSignedByKnownQuorum) + // We need to prove this intermediate quorum too + // NOTE: We don't add the signing quorum's pubkey to knownQuorumPubKeys here because + // verification happens in reverse order - intermediate quorums are proven AFTER the + // quorums that depend on them, so they can't be used as trust anchors during building. - // Check if the signing quorum's public key is in the checkpoint's known quorums + // Safety check: this should never match since FindChainlockSignedByKnownQuorum already searched if (knownQuorumPubKeys.count(signingQuorum->qc->quorumPublicKey)) { - // We've reached a quorum that's trusted by the checkpoint - done! + // We've reached a quorum that's in the checkpoint - done! + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Signing quorum's public key is in checkpoint - chain complete!\n", __func__); break; } + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Signing quorum not in checkpoint, need to prove it too\n", __func__); // The signing quorum is not in the checkpoint, so we need to prove it first currentQuorum = signingQuorum; } @@ -493,9 +606,12 @@ std::optional CQuorumProofManager::BuildProofChain( return std::nullopt; } - // Get the block hash at the chainlock height - const CBlockIndex* pClBlock = step.quorum->m_quorum_base_block_index->GetAncestor(step.chainlockHeight); + // Get the block at the chainlock height from the active chain + // Note: chainlock height can be >= mined block height, so we can't use GetAncestor + const CBlockIndex* pClBlock = active_chain[step.chainlockHeight]; if (!pClBlock) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- FAILED: Could not get block at chainlock height %d from active chain\n", + __func__, step.chainlockHeight); return std::nullopt; } @@ -507,8 +623,8 @@ std::optional CQuorumProofManager::BuildProofChain( includedChainlockHeights.insert(step.chainlockHeight); } - // Build the quorum commitment proof - const CBlockIndex* pMinedBlock = step.quorum->m_quorum_base_block_index; + // Build the quorum commitment proof - use the MINED block where the commitment is + const CBlockIndex* pMinedBlock = step.pMinedBlockIndex; auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.quorum->qc->llmqType, step.quorum->qc->quorumHash); if (!merkleProof.has_value()) { @@ -691,4 +807,102 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( return result; } +void CQuorumProofManager::MigrateChainlockIndex(const CChain& active_chain, const CChainParams& chainparams) +{ + // Check if migration is needed + int version{0}; + if (m_evoDb.Read(DB_CHAINLOCK_INDEX_VERSION, version) && version >= CHAINLOCK_INDEX_VERSION) { + LogPrintf("CQuorumProofManager: Chainlock index is up to date (version %d)\n", version); + return; + } + + LogPrintf("CQuorumProofManager: Building chainlock index from historical blocks...\n"); + + // Start from V20 activation height - chainlocks in cbtx (CLSIG_AND_BALANCE) were introduced in V20 + const int v20Height = chainparams.GetConsensus().V20Height; + const CBlockIndex* pindex = active_chain[v20Height]; + if (!pindex) { + // V20 not yet reached, nothing to migrate + LogPrintf("CQuorumProofManager: V20 not yet active (height %d), skipping migration\n", v20Height); + // Still write version so we don't check every startup + m_evoDb.Write(DB_CHAINLOCK_INDEX_VERSION, CHAINLOCK_INDEX_VERSION); + return; + } + + int indexed_count = 0; + int blocks_processed = 0; + const int tip_height = active_chain.Height(); + const int total_blocks = tip_height - v20Height + 1; + + LogPrintf("CQuorumProofManager: Starting migration from V20 height %d to tip %d (%d blocks)\n", + v20Height, tip_height, total_blocks); + + // Show initial progress in UI + uiInterface.ShowProgress(_("Building chainlock index…").translated, 0, false); + + // Iterate through blocks from V20 activation + while (pindex) { + blocks_processed++; + + // Update progress every 10000 blocks + if (blocks_processed % 10000 == 0) { + int percentageDone = std::max(1, std::min(99, (int)((double)blocks_processed / total_blocks * 100))); + uiInterface.ShowProgress(_("Building chainlock index…").translated, percentageDone, false); + LogPrintf("CQuorumProofManager: Migration progress: %d/%d blocks processed (%d%%), %d chainlocks indexed\n", + blocks_processed, total_blocks, percentageDone, indexed_count); + } + + // Read block from disk + CBlock block; + if (!ReadBlockFromDisk(block, pindex, chainparams.GetConsensus())) { + LogPrintf("CQuorumProofManager: Failed to read block at height %d, skipping\n", pindex->nHeight); + pindex = active_chain.Next(pindex); + continue; + } + + // Check if block has transactions + if (block.vtx.empty()) { + pindex = active_chain.Next(pindex); + continue; + } + + // Try to extract CCbTx from coinbase + auto opt_cbtx = GetTxPayload(*block.vtx[0]); + if (!opt_cbtx.has_value()) { + pindex = active_chain.Next(pindex); + continue; + } + + const CCbTx& cbtx = opt_cbtx.value(); + + // Check if this cbtx contains a chainlock signature + if (cbtx.bestCLSignature.IsValid()) { + // Calculate the chainlocked height + int32_t chainlockedHeight = pindex->nHeight - static_cast(cbtx.bestCLHeightDiff) - 1; + const CBlockIndex* pChainlockedBlock = pindex->GetAncestor(chainlockedHeight); + + if (pChainlockedBlock) { + IndexChainlock( + chainlockedHeight, + pChainlockedBlock->GetBlockHash(), + cbtx.bestCLSignature, + pindex->GetBlockHash(), + pindex->nHeight); + indexed_count++; + } + } + + pindex = active_chain.Next(pindex); + } + + // Write version to mark migration complete + m_evoDb.Write(DB_CHAINLOCK_INDEX_VERSION, CHAINLOCK_INDEX_VERSION); + + // Hide progress indicator + uiInterface.ShowProgress("", 100, false); + + LogPrintf("CQuorumProofManager: Chainlock index migration complete. Processed %d blocks, indexed %d chainlocks\n", + blocks_processed, indexed_count); +} + } // namespace llmq diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index 8662680962f1..643be6bbece5 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -13,12 +13,18 @@ #include #include +#include #include class CBlockIndex; class CChain; +class CChainParams; class CEvoDB; +namespace node { +class BlockManager; +} // namespace node + namespace llmq { class CQuorumBlockProcessor; @@ -169,6 +175,14 @@ class CQuorumProofManager { // Helper to find the first chainlock covering a block [[nodiscard]] int32_t FindChainlockCoveringBlock(const CBlockIndex* pMinedBlock) const; + // Helper to find a chainlock covering a block that is signed by a known quorum + // This allows us to skip intermediate quorums when a direct path exists + [[nodiscard]] int32_t FindChainlockSignedByKnownQuorum( + const CBlockIndex* pMinedBlock, + const std::set& knownQuorumPubKeys, + const CChain& active_chain, + const CQuorumManager& qman) const; + public: CQuorumProofManager(CEvoDB& evoDb, const CQuorumBlockProcessor& quorum_block_processor) : m_evoDb(evoDb), m_quorum_block_processor(quorum_block_processor) {} @@ -196,7 +210,8 @@ class CQuorumProofManager { Consensus::LLMQType targetQuorumType, const uint256& targetQuorumHash, const CQuorumManager& qman, - const CChain& active_chain) const; + const CChain& active_chain, + const node::BlockManager& block_man) const; // Proof Chain Verification [[nodiscard]] QuorumProofVerifyResult VerifyProofChain( @@ -204,18 +219,29 @@ class CQuorumProofManager { const QuorumProofChain& proof, Consensus::LLMQType expectedType, const uint256& expectedQuorumHash) const; + + // Migration: Build chainlock index from historical blocks + // Should be called once during startup after chain is loaded + void MigrateChainlockIndex(const CChain& active_chain, const CChainParams& chainparams); }; // Database key prefix for chainlock index static const std::string DB_CHAINLOCK_BY_HEIGHT = "q_clh"; +// Database key for chainlock index version (for migration tracking) +static const std::string DB_CHAINLOCK_INDEX_VERSION = "q_clv"; + +// Current version of the chainlock index +// Increment this when the index format changes to trigger re-migration +static constexpr int CHAINLOCK_INDEX_VERSION = 2; + // Maximum merkle path length (DoS protection) // A path of 32 levels can support 2^32 leaves, which is more than sufficient static constexpr size_t MAX_MERKLE_PATH_LENGTH = 32; // Maximum proof chain length (DoS protection) // Limits how many intermediate quorums can be proven in a single chain -static constexpr size_t MAX_PROOF_CHAIN_LENGTH = 50; +static constexpr size_t MAX_PROOF_CHAIN_LENGTH = 500; // Maximum height offset to search for a chainlock covering a block // This limits how far forward we search from a block's height to find coverage diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 636ca29d6163..98625e247cff 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -274,6 +274,10 @@ static const CRPCConvertParam vRPCConvertParams[] = { "protx update_service_evo", 2, "coreP2PAddrs", true }, { "protx update_service_evo", 5, "platformP2PAddrs", true }, { "protx update_service_evo", 6, "platformHTTPSAddrs", true }, + { "getquorumproofchain", 0, "checkpoint" }, + { "getquorumproofchain", 2, "llmq_type" }, + { "verifyquorumproofchain", 0, "checkpoint" }, + { "verifyquorumproofchain", 3, "llmq_type" }, }; // clang-format on diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 55a755466cdb..e4afb1ba189e 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -1385,7 +1385,7 @@ static RPCHelpMan getquorumproofchain() } auto proofChain = llmq_ctx.quorum_proof_manager->BuildProofChain( - checkpoint, targetType, targetQuorumHash, *llmq_ctx.qman, chainman.ActiveChain()); + checkpoint, targetType, targetQuorumHash, *llmq_ctx.qman, chainman.ActiveChain(), chainman.m_blockman); if (!proofChain.has_value()) { throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Failed to build proof chain - quorum not found or no chainlock coverage"); From 8bad536bc20c28e2b2363071ad0f48457be85eeb Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 15:40:08 -0600 Subject: [PATCH 04/12] fix(llmq): optimize quorum proof chain for size and performance This optimization significantly reduces the proof size and generation time for long quorum proof chains by intelligently selecting blocks to prove. Key improvements: - Smart Block Selection: Searches the active quorum window (up to 100 blocks) instead of relying solely on the mined block. - Direct Bridging: Prioritizes blocks signed by known quorums to eliminate intermediate steps. - Proof Size Reduction: Preferentially selects non-superblock blocks to minimize coinbase transaction size. - Efficient Bridging: When no direct bridge exists, selects the oldest signing quorum to maximize the backward jump. Benchmark Results: - ~30 days: Steps reduced by 38% (26 -> 16), Size reduced by 38% (36KB -> 22KB). - ~6 months: Steps reduced by 40% (150 -> 90), Size reduced by 40% (207KB -> 123KB). Time improved by ~24%. - ~12 months: Steps reduced by 37% (292 -> 185), Size reduced by 37% (402KB -> 252KB). Time improved by ~20%. --- src/llmq/quorumproofs.cpp | 168 +++++++++++++++++++++++++------------- 1 file changed, 109 insertions(+), 59 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index b1482013f882..6e0d392126fe 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -524,71 +524,121 @@ std::optional CQuorumProofManager::BuildProofChain( } // Look up the block where the commitment was actually mined - // This is different from the quorum's base block (where the quorum was formed) const CBlockIndex* pMinedBlock = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(currentQuorum->minedBlockHash)); if (!pMinedBlock) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not find mined block %s for quorum %s\n", __func__, currentQuorum->minedBlockHash.ToString(), currentQuorum->qc->quorumHash.ToString()); return std::nullopt; } - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Quorum %s: formed at height %d, commitment mined at height %d\n", - __func__, currentQuorum->qc->quorumHash.ToString(), - currentQuorum->m_quorum_base_block_index ? currentQuorum->m_quorum_base_block_index->nHeight : -1, - pMinedBlock->nHeight); - - // First, try to find a chainlock signed by a KNOWN quorum (direct path) - // This is the key optimization: instead of taking any chainlock and hoping - // its signer is known, we actively search for one signed by a known quorum - int32_t chainlockHeight = FindChainlockSignedByKnownQuorum(pMinedBlock, knownQuorumPubKeys, active_chain, qman); - - if (chainlockHeight >= 0) { - // Found a direct path! The signing quorum is already known - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Found DIRECT path via chainlock at height %d\n", - __func__, chainlockHeight); - proofSteps.push_back({currentQuorum, pMinedBlock, chainlockHeight}); - break; // Chain complete! - } - - // No direct path found - fall back to finding any chainlock - // and adding the signing quorum to the proof chain - chainlockHeight = FindChainlockCoveringBlock(pMinedBlock); - if (chainlockHeight < 0) { - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No chainlock found covering block at height %d (searched up to %d)\n", - __func__, pMinedBlock->nHeight, pMinedBlock->nHeight + MAX_CHAINLOCK_SEARCH_OFFSET); - return std::nullopt; // No chainlock found covering this quorum - } - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No direct path, using chainlock at height %d\n", - __func__, chainlockHeight); - - proofSteps.push_back({currentQuorum, pMinedBlock, chainlockHeight}); - - // Determine which quorum signed this chainlock - CQuorumCPtr signingQuorum = DetermineChainlockSigningQuorum(chainlockHeight, active_chain, qman); - if (!signingQuorum) { - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not determine signing quorum for chainlock at height %d\n", - __func__, chainlockHeight); - return std::nullopt; // Could not determine signing quorum - } - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Chainlock signed by quorum %s (type %d) pubkey=%s\n", - __func__, signingQuorum->qc->quorumHash.ToString(), static_cast(signingQuorum->qc->llmqType), - signingQuorum->qc->quorumPublicKey.ToString().substr(0, 32) + "..."); - - // The signing quorum is NOT in the checkpoint (we already checked via FindChainlockSignedByKnownQuorum) - // We need to prove this intermediate quorum too - // NOTE: We don't add the signing quorum's pubkey to knownQuorumPubKeys here because - // verification happens in reverse order - intermediate quorums are proven AFTER the - // quorums that depend on them, so they can't be used as trust anchors during building. - - // Safety check: this should never match since FindChainlockSignedByKnownQuorum already searched - if (knownQuorumPubKeys.count(signingQuorum->qc->quorumPublicKey)) { - // We've reached a quorum that's in the checkpoint - done! - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Signing quorum's public key is in checkpoint - chain complete!\n", __func__); - break; - } - - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Signing quorum not in checkpoint, need to prove it too\n", __func__); - // The signing quorum is not in the checkpoint, so we need to prove it first - currentQuorum = signingQuorum; + + // OPTIMIZATION: Search for the best block to prove this quorum + // We can use ANY block where the quorum is active, not just the mined block. + // We prioritize: + // 1. Blocks signed by a KNOWN quorum (direct bridge) + // 2. Blocks that are NOT superblocks (small proof size) + // 3. Blocks signed by the oldest possible quorum (maximize jump) + + const auto llmq_params = Params().GetLLMQ(currentQuorum->qc->llmqType).value(); + // Quorum is active for signingActiveQuorumCount * dkgInterval blocks + int activeDuration = std::min(llmq_params.signingActiveQuorumCount * llmq_params.dkgInterval, 100); + int maxSearchHeight = std::min(active_chain.Height(), pMinedBlock->nHeight + activeDuration); + + int32_t bestBlockHeight = -1; + int32_t bestChainlockHeight = -1; + CQuorumCPtr bestSigningQuorum = nullptr; + + // Metrics for selection + bool foundKnownSigner = false; + bool foundNonSuperblock = false; + int32_t oldestSignerHeight = std::numeric_limits::max(); + + // Search window. For performance, if we don't find a known signer quickly, we might limit search. + // But finding a known signer is the biggest optimization, so we search aggressively. + for (int32_t h = pMinedBlock->nHeight; h <= maxSearchHeight; ++h) { + // Get chainlock for this height + auto clEntry = GetChainlockByHeight(h); + if (!clEntry.has_value()) continue; + + // Determine signer + CQuorumCPtr signer = DetermineChainlockSigningQuorum(h, active_chain, qman); + if (!signer) continue; + + bool isKnown = knownQuorumPubKeys.count(signer->qc->quorumPublicKey); + // Check for superblock (heuristic: check if height is superblock cycle) + // We can't easily check actual coinbase size without reading block, but we know schedule. + bool isSuperblock = (h % Params().GetConsensus().nSuperblockCycle == 0); // Simplified check + + if (isKnown) { + // Found a direct bridge! + if (!foundKnownSigner) { + // First known signer found, take it! + bestBlockHeight = h; + bestChainlockHeight = h; // Chainlock is at height h + bestSigningQuorum = signer; + foundKnownSigner = true; + // Preference: Non-superblock if possible + if (!isSuperblock) foundNonSuperblock = true; + } else { + // Already found a known signer, but prefer non-superblock + if (!foundNonSuperblock && !isSuperblock) { + bestBlockHeight = h; + bestChainlockHeight = h; + bestSigningQuorum = signer; + foundNonSuperblock = true; + } + } + // If we found a known signer that is not a superblock, we are golden. + if (foundKnownSigner && foundNonSuperblock) break; + } else if (!foundKnownSigner) { + // Not known, but maybe better than current best? + // We want oldest signer (closest to checkpoint) + // And prefer non-superblock + int32_t signerHeight = signer->m_quorum_base_block_index->nHeight; + + bool isBetter = false; + if (bestBlockHeight == -1) { + isBetter = true; + } else { + // Logic: Prefer Non-Superblock >> Oldest Signer + if (!foundNonSuperblock && !isSuperblock) { + isBetter = true; // Found a non-superblock! + } else if (foundNonSuperblock == !isSuperblock) { + // Both are same class (both SB or both non-SB), pick oldest signer + if (signerHeight < oldestSignerHeight) { + isBetter = true; + } + } + } + + if (isBetter) { + bestBlockHeight = h; + bestChainlockHeight = h; + bestSigningQuorum = signer; + oldestSignerHeight = signerHeight; + if (!isSuperblock) foundNonSuperblock = true; + } + } + } + + if (bestBlockHeight == -1) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No suitable chainlock found in active window [%d, %d]\n", + __func__, pMinedBlock->nHeight, maxSearchHeight); + return std::nullopt; + } + + const CBlockIndex* pProofBlock = active_chain[bestBlockHeight]; + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Selected proof block %d (mined %d). KnownSigner=%d, SignerHeight=%d\n", + __func__, bestBlockHeight, pMinedBlock->nHeight, foundKnownSigner, bestSigningQuorum->m_quorum_base_block_index->nHeight); + + proofSteps.push_back({currentQuorum, pProofBlock, bestChainlockHeight}); + + if (foundKnownSigner) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Signing quorum's public key is in checkpoint - chain complete!\n", __func__); + break; + } + + // Need to prove the signer + currentQuorum = bestSigningQuorum; } // Phase 3: Build proofs in forward order (reverse the dependency chain) From e1d2eea426919dfbf462034f5e6518f3bf977c7b Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 15:48:28 -0600 Subject: [PATCH 05/12] fix(llmq): simplify proof chain search to prioritize speed Removed the 'superblock avoidance' heuristic which was causing unnecessary search overhead. The algorithm now terminates immediately upon finding a block signed by a known quorum (direct bridge). Benchmark Results (~12 months): - Time: Reduced from 20.6s to 15.3s (~25% faster) - Steps/Size: Identical --- src/llmq/quorumproofs.cpp | 46 ++++++++++----------------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index 6e0d392126fe..964ad1314fcc 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -549,7 +549,6 @@ std::optional CQuorumProofManager::BuildProofChain( // Metrics for selection bool foundKnownSigner = false; - bool foundNonSuperblock = false; int32_t oldestSignerHeight = std::numeric_limits::max(); // Search window. For performance, if we don't find a known signer quickly, we might limit search. @@ -564,49 +563,27 @@ std::optional CQuorumProofManager::BuildProofChain( if (!signer) continue; bool isKnown = knownQuorumPubKeys.count(signer->qc->quorumPublicKey); - // Check for superblock (heuristic: check if height is superblock cycle) - // We can't easily check actual coinbase size without reading block, but we know schedule. - bool isSuperblock = (h % Params().GetConsensus().nSuperblockCycle == 0); // Simplified check if (isKnown) { // Found a direct bridge! - if (!foundKnownSigner) { - // First known signer found, take it! - bestBlockHeight = h; - bestChainlockHeight = h; // Chainlock is at height h - bestSigningQuorum = signer; - foundKnownSigner = true; - // Preference: Non-superblock if possible - if (!isSuperblock) foundNonSuperblock = true; - } else { - // Already found a known signer, but prefer non-superblock - if (!foundNonSuperblock && !isSuperblock) { - bestBlockHeight = h; - bestChainlockHeight = h; - bestSigningQuorum = signer; - foundNonSuperblock = true; - } - } - // If we found a known signer that is not a superblock, we are golden. - if (foundKnownSigner && foundNonSuperblock) break; + bestBlockHeight = h; + bestChainlockHeight = h; // Chainlock is at height h + bestSigningQuorum = signer; + foundKnownSigner = true; + break; // Found a known signer, we are done searching for this step. } else if (!foundKnownSigner) { - // Not known, but maybe better than current best? - // We want oldest signer (closest to checkpoint) - // And prefer non-superblock + // Not known. We want the one that maximizes the jump back. + // The jump size is determined by the signer's creation height. + // We want the signer with the lowest creation height (oldest). int32_t signerHeight = signer->m_quorum_base_block_index->nHeight; bool isBetter = false; if (bestBlockHeight == -1) { isBetter = true; } else { - // Logic: Prefer Non-Superblock >> Oldest Signer - if (!foundNonSuperblock && !isSuperblock) { - isBetter = true; // Found a non-superblock! - } else if (foundNonSuperblock == !isSuperblock) { - // Both are same class (both SB or both non-SB), pick oldest signer - if (signerHeight < oldestSignerHeight) { - isBetter = true; - } + // Pick oldest signer + if (signerHeight < oldestSignerHeight) { + isBetter = true; } } @@ -615,7 +592,6 @@ std::optional CQuorumProofManager::BuildProofChain( bestChainlockHeight = h; bestSigningQuorum = signer; oldestSignerHeight = signerHeight; - if (!isSuperblock) foundNonSuperblock = true; } } } From 5e588cef720964c33f01b35d92e2bef78a214148 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 16:13:21 -0600 Subject: [PATCH 06/12] Optimize quorum proof chain generation Avoid unnecessary DB lookups in chainlock search loop by pruning uninteresting signers early. Eliminate redundant block reads by passing block pointer to BuildQuorumMerkleProof. Cache ChainlockIndexEntry in ProofStep to prevent re-reading during proof construction. These changes reduce proof generation time for long chains (e.g., 12 months) by ~12-13%. --- src/llmq/quorumproofs.cpp | 54 +++++++++++++++++++++++++++------------ src/llmq/quorumproofs.h | 3 ++- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index 964ad1314fcc..aeaba5532e6d 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -317,7 +317,8 @@ static std::pair, std::vector> BuildMerkleProofPath( std::optional CQuorumProofManager::BuildQuorumMerkleProof( const CBlockIndex* pindex, Consensus::LLMQType llmqType, - const uint256& quorumHash) const + const uint256& quorumHash, + const CBlock* pBlock) const { if (pindex == nullptr || pindex->pprev == nullptr) { return std::nullopt; @@ -351,10 +352,15 @@ std::optional CQuorumProofManager::BuildQuorumMerkleProof( // Now add commitments from the current block's transactions (matching CalcCbTxMerkleRootQuorums logic) // This is necessary because GetMinedAndActiveCommitmentsUntilBlock uses pindexPrev - CBlock block; - if (!ReadBlockFromDisk(block, pindex, Params().GetConsensus())) { - return std::nullopt; + CBlock block_local; + const CBlock* block_ptr = pBlock; + if (!block_ptr) { + if (!ReadBlockFromDisk(block_local, pindex, Params().GetConsensus())) { + return std::nullopt; + } + block_ptr = &block_local; } + const CBlock& block = *block_ptr; for (size_t i = 1; i < block.vtx.size(); ++i) { const auto& tx = block.vtx[i]; @@ -496,6 +502,7 @@ std::optional CQuorumProofManager::BuildProofChain( CQuorumCPtr quorum; const CBlockIndex* pMinedBlockIndex; // Block where commitment was actually mined int32_t chainlockHeight; + std::optional chainlockEntry; }; std::vector proofSteps; std::set visitedQuorums; // Cycle detection @@ -546,6 +553,7 @@ std::optional CQuorumProofManager::BuildProofChain( int32_t bestBlockHeight = -1; int32_t bestChainlockHeight = -1; CQuorumCPtr bestSigningQuorum = nullptr; + std::optional bestChainlockEntry; // Metrics for selection bool foundKnownSigner = false; @@ -554,28 +562,38 @@ std::optional CQuorumProofManager::BuildProofChain( // Search window. For performance, if we don't find a known signer quickly, we might limit search. // But finding a known signer is the biggest optimization, so we search aggressively. for (int32_t h = pMinedBlock->nHeight; h <= maxSearchHeight; ++h) { - // Get chainlock for this height - auto clEntry = GetChainlockByHeight(h); - if (!clEntry.has_value()) continue; - // Determine signer CQuorumCPtr signer = DetermineChainlockSigningQuorum(h, active_chain, qman); if (!signer) continue; bool isKnown = knownQuorumPubKeys.count(signer->qc->quorumPublicKey); + int32_t signerHeight = signer->m_quorum_base_block_index->nHeight; + + // Optimization: Skip DB lookup if this signer is not interesting + // We are interested if: + // 1. It's a known signer (direct bridge) + // 2. We haven't found any candidate yet + // 3. It's strictly better (older) than our current best candidate + bool isInteresting = isKnown || bestBlockHeight == -1 || signerHeight < oldestSignerHeight; + + if (!isInteresting) continue; + + // Get chainlock for this height + auto clEntry = GetChainlockByHeight(h); + if (!clEntry.has_value()) continue; if (isKnown) { // Found a direct bridge! bestBlockHeight = h; bestChainlockHeight = h; // Chainlock is at height h bestSigningQuorum = signer; + bestChainlockEntry = clEntry; foundKnownSigner = true; break; // Found a known signer, we are done searching for this step. } else if (!foundKnownSigner) { // Not known. We want the one that maximizes the jump back. // The jump size is determined by the signer's creation height. // We want the signer with the lowest creation height (oldest). - int32_t signerHeight = signer->m_quorum_base_block_index->nHeight; bool isBetter = false; if (bestBlockHeight == -1) { @@ -591,6 +609,7 @@ std::optional CQuorumProofManager::BuildProofChain( bestBlockHeight = h; bestChainlockHeight = h; bestSigningQuorum = signer; + bestChainlockEntry = clEntry; oldestSignerHeight = signerHeight; } } @@ -606,7 +625,7 @@ std::optional CQuorumProofManager::BuildProofChain( LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Selected proof block %d (mined %d). KnownSigner=%d, SignerHeight=%d\n", __func__, bestBlockHeight, pMinedBlock->nHeight, foundKnownSigner, bestSigningQuorum->m_quorum_base_block_index->nHeight); - proofSteps.push_back({currentQuorum, pProofBlock, bestChainlockHeight}); + proofSteps.push_back({currentQuorum, pProofBlock, bestChainlockHeight, bestChainlockEntry}); if (foundKnownSigner) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Signing quorum's public key is in checkpoint - chain complete!\n", __func__); @@ -627,7 +646,10 @@ std::optional CQuorumProofManager::BuildProofChain( for (const auto& step : proofSteps) { // Add chainlock entry if not already included if (!includedChainlockHeights.count(step.chainlockHeight)) { - auto clEntry = GetChainlockByHeight(step.chainlockHeight); + std::optional clEntry = step.chainlockEntry; + if (!clEntry.has_value()) { + clEntry = GetChainlockByHeight(step.chainlockHeight); + } if (!clEntry.has_value()) { return std::nullopt; } @@ -652,17 +674,17 @@ std::optional CQuorumProofManager::BuildProofChain( // Build the quorum commitment proof - use the MINED block where the commitment is const CBlockIndex* pMinedBlock = step.pMinedBlockIndex; - auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.quorum->qc->llmqType, step.quorum->qc->quorumHash); - if (!merkleProof.has_value()) { - return std::nullopt; - } - // Read the block to get coinbase transaction CBlock block; if (!ReadBlockFromDisk(block, pMinedBlock, Params().GetConsensus())) { return std::nullopt; } + auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.quorum->qc->llmqType, step.quorum->qc->quorumHash, &block); + if (!merkleProof.has_value()) { + return std::nullopt; + } + // Build coinbase merkle proof std::vector txHashes; for (const auto& tx : block.vtx) { diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index 643be6bbece5..c0f4444718fb 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -202,7 +202,8 @@ class CQuorumProofManager { [[nodiscard]] std::optional BuildQuorumMerkleProof( const CBlockIndex* pindex, Consensus::LLMQType llmqType, - const uint256& quorumHash) const; + const uint256& quorumHash, + const CBlock* pBlock = nullptr) const; // Proof Chain Generation [[nodiscard]] std::optional BuildProofChain( From 1dfc02e23b1703335b9c5cd0a23813677b111601 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 16:30:40 -0600 Subject: [PATCH 07/12] Optimize quorum proof generation performance - Implement GetMinedCommitmentTxHash and GetMinedCommitmentBlockHash in CQuorumBlockProcessor to avoid expensive BLS deserialization. - Add ScanCommitments and SelectCommitmentForSigning to CQuorumManager to avoid building full CQuorum objects and MN lists during scans. - Update CQuorumProofManager to utilize these optimized methods for BuildQuorumMerkleProof and BuildProofChain. - Significantly reduces CPU usage and time for generating long proof chains. --- src/llmq/blockprocessor.cpp | 56 ++++++++++++ src/llmq/blockprocessor.h | 2 + src/llmq/quorumproofs.cpp | 95 ++++++++++++-------- src/llmq/quorumproofs.h | 2 +- src/llmq/quorumsman.cpp | 170 ++++++++++++++++++++++++++++++++++++ src/llmq/quorumsman.h | 11 +++ 6 files changed, 297 insertions(+), 39 deletions(-) diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 86b9617ff004..28f8bace3504 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -525,6 +526,61 @@ std::pair CQuorumBlockProcessor::GetMinedCommitment(C return ret; } +uint256 CQuorumBlockProcessor::GetMinedCommitmentTxHash(Consensus::LLMQType llmqType, const uint256& quorumHash) const +{ + auto key = std::make_pair(DB_MINED_COMMITMENT, std::make_pair(llmqType, quorumHash)); + CDataStream ssKey(SER_DISK, CLIENT_VERSION); + ssKey << key; + + // Fast path: try to read raw data from disk to avoid deserializing BLS keys + CDataStream ssValue(SER_DISK, CLIENT_VERSION); + if (m_evoDb.GetRawDB().ReadDataStream(ssKey, ssValue)) { + // The data in DB is std::pair + // It's serialized as: [CFinalCommitment serialized data][uint256 serialized data] + // uint256 is exactly 32 bytes + if (ssValue.size() > 32) { + // We just want the hash of the CFinalCommitment part + // SerializeHash uses SER_GETHASH, but we have SER_DISK bytes. + // CFinalCommitment serialization is identical for both (as long as nVersion matches). + // We trust the data in DB is consistent. + return Hash(MakeByteSpan(ssValue).first(ssValue.size() - 32)); + } + } + + // Fallback: use slow path (read from memory/cache or if disk read failed) + // This will deserialize the full object + auto [commitment, _] = GetMinedCommitment(llmqType, quorumHash); + if (commitment.IsNull()) { + return uint256::ZERO; + } + return ::SerializeHash(commitment); +} + +uint256 CQuorumBlockProcessor::GetMinedCommitmentBlockHash(Consensus::LLMQType llmqType, const uint256& quorumHash) const +{ + auto key = std::make_pair(DB_MINED_COMMITMENT, std::make_pair(llmqType, quorumHash)); + CDataStream ssKey(SER_DISK, CLIENT_VERSION); + ssKey << key; + + // Fast path: try to read raw data from disk + CDataStream ssValue(SER_DISK, CLIENT_VERSION); + if (m_evoDb.GetRawDB().ReadDataStream(ssKey, ssValue)) { + // The data in DB is std::pair + // It's serialized as: [CFinalCommitment serialized data][uint256 serialized data] + // uint256 is exactly 32 bytes and it is at the end + if (ssValue.size() >= 32) { + uint256 blockHash; + // Read last 32 bytes + std::memcpy(blockHash.begin(), ssValue.data() + ssValue.size() - 32, 32); + return blockHash; + } + } + + // Fallback: use slow path + auto [_, blockHash] = GetMinedCommitment(llmqType, quorumHash); + return blockHash; +} + // The returned quorums are in reversed order, so the most recent one is at index 0 std::vector CQuorumBlockProcessor::GetMinedCommitmentsUntilBlock(Consensus::LLMQType llmqType, gsl::not_null pindex, size_t maxCount) const { diff --git a/src/llmq/blockprocessor.h b/src/llmq/blockprocessor.h index e44506131036..50eb1b1b5820 100644 --- a/src/llmq/blockprocessor.h +++ b/src/llmq/blockprocessor.h @@ -83,6 +83,8 @@ class CQuorumBlockProcessor bool HasMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash) const EXCLUSIVE_LOCKS_REQUIRED(!minableCommitmentsCs); std::pair GetMinedCommitment(Consensus::LLMQType llmqType, const uint256& quorumHash) const; + uint256 GetMinedCommitmentTxHash(Consensus::LLMQType llmqType, const uint256& quorumHash) const; + uint256 GetMinedCommitmentBlockHash(Consensus::LLMQType llmqType, const uint256& quorumHash) const; std::vector GetMinedCommitmentsUntilBlock(Consensus::LLMQType llmqType, gsl::not_null pindex, size_t maxCount) const; std::map> GetMinedAndActiveCommitmentsUntilBlock(gsl::not_null pindex) const; diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index aeaba5532e6d..f0fef2bf0641 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -335,15 +335,17 @@ std::optional CQuorumProofManager::BuildQuorumMerkleProof( for (const auto& [type, blockIndexes] : commitmentsMap) { for (const auto* blockIndex : blockIndexes) { - auto [commitment, minedBlockHash] = m_quorum_block_processor.GetMinedCommitment(type, blockIndex->GetBlockHash()); - if (minedBlockHash == uint256::ZERO) { + // Optimization: Get hash directly without deserialization + // The mined commitment's quorumHash is the blockHash of the blockIndex + uint256 commitmentHash = m_quorum_block_processor.GetMinedCommitmentTxHash(type, blockIndex->GetBlockHash()); + + if (commitmentHash == uint256::ZERO) { continue; } - uint256 commitmentHash = ::SerializeHash(commitment); commitmentHashes.push_back(commitmentHash); - if (type == llmqType && commitment.quorumHash == quorumHash) { + if (type == llmqType && blockIndex->GetBlockHash() == quorumHash) { targetCommitmentHash = commitmentHash; targetFound = true; } @@ -443,17 +445,17 @@ int32_t CQuorumProofManager::FindChainlockSignedByKnownQuorum( } // Check if the signing quorum for this chainlock height is in our known set - CQuorumCPtr signingQuorum = DetermineChainlockSigningQuorum(height, active_chain, qman); - if (signingQuorum && knownQuorumPubKeys.count(signingQuorum->qc->quorumPublicKey)) { + auto signingCommitment = DetermineChainlockSigningCommitment(height, active_chain, qman); + if (signingCommitment && knownQuorumPubKeys.count(signingCommitment->quorumPublicKey)) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Found chainlock at height %d signed by known quorum %s\n", - __func__, height, signingQuorum->qc->quorumHash.ToString()); + __func__, height, signingCommitment->quorumHash.ToString()); return height; } } return -1; } -CQuorumCPtr CQuorumProofManager::DetermineChainlockSigningQuorum( +std::optional CQuorumProofManager::DetermineChainlockSigningCommitment( int32_t chainlockHeight, const CChain& active_chain, const CQuorumManager& qman) const @@ -462,15 +464,15 @@ CQuorumCPtr CQuorumProofManager::DetermineChainlockSigningQuorum( const auto llmqType = Params().GetConsensus().llmqTypeChainLocks; const auto& llmq_params_opt = Params().GetLLMQ(llmqType); if (!llmq_params_opt.has_value()) { - return nullptr; + return std::nullopt; } const auto& llmq_params = llmq_params_opt.value(); // Generate the request ID for the chainlock at this height const uint256 requestId = chainlock::GenSigRequestId(chainlockHeight); - // Use the existing SelectQuorumForSigning logic - return SelectQuorumForSigning(llmq_params, active_chain, qman, + // Use SelectCommitmentForSigning which avoids building full quorum objects + return SelectCommitmentForSigning(llmq_params, active_chain, qman, requestId, chainlockHeight, SIGN_HEIGHT_OFFSET); } @@ -499,7 +501,7 @@ std::optional CQuorumProofManager::BuildProofChain( // Each ProofStep represents a quorum that needs to be proven and // the chainlock height that covers its mined block struct ProofStep { - CQuorumCPtr quorum; + CFinalCommitment commitment; const CBlockIndex* pMinedBlockIndex; // Block where commitment was actually mined int32_t chainlockHeight; std::optional chainlockEntry; @@ -508,20 +510,24 @@ std::optional CQuorumProofManager::BuildProofChain( std::set visitedQuorums; // Cycle detection // Start with the target quorum - CQuorumCPtr currentQuorum = qman.GetQuorum(targetQuorumType, targetQuorumHash); - if (!currentQuorum) { - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Target quorum not found in quorum manager\n", __func__); + // For the first step, we need the full commitment object and we need to know where it was mined. + // We use GetMinedCommitment which gives us both. + auto [targetQc, targetMinedHash] = m_quorum_block_processor.GetMinedCommitment(targetQuorumType, targetQuorumHash); + + if (targetQc.IsNull()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Target quorum not found\n", __func__); return std::nullopt; } - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Found target quorum, formed at height %d\n", - __func__, currentQuorum->m_quorum_base_block_index ? currentQuorum->m_quorum_base_block_index->nHeight : -1); + + CFinalCommitment currentCommitment = targetQc; + uint256 currentMinedBlockHash = targetMinedHash; while (true) { // Cycle detection - if (visitedQuorums.count(currentQuorum->qc->quorumHash)) { + if (visitedQuorums.count(currentCommitment.quorumHash)) { return std::nullopt; // Cycle detected - invalid chain } - visitedQuorums.insert(currentQuorum->qc->quorumHash); + visitedQuorums.insert(currentCommitment.quorumHash); // DoS protection: limit chain length if (proofSteps.size() >= MAX_PROOF_CHAIN_LENGTH) { @@ -531,10 +537,10 @@ std::optional CQuorumProofManager::BuildProofChain( } // Look up the block where the commitment was actually mined - const CBlockIndex* pMinedBlock = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(currentQuorum->minedBlockHash)); + const CBlockIndex* pMinedBlock = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(currentMinedBlockHash)); if (!pMinedBlock) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not find mined block %s for quorum %s\n", - __func__, currentQuorum->minedBlockHash.ToString(), currentQuorum->qc->quorumHash.ToString()); + __func__, currentMinedBlockHash.ToString(), currentCommitment.quorumHash.ToString()); return std::nullopt; } @@ -545,14 +551,15 @@ std::optional CQuorumProofManager::BuildProofChain( // 2. Blocks that are NOT superblocks (small proof size) // 3. Blocks signed by the oldest possible quorum (maximize jump) - const auto llmq_params = Params().GetLLMQ(currentQuorum->qc->llmqType).value(); + const auto llmq_params = Params().GetLLMQ(currentCommitment.llmqType).value(); // Quorum is active for signingActiveQuorumCount * dkgInterval blocks int activeDuration = std::min(llmq_params.signingActiveQuorumCount * llmq_params.dkgInterval, 100); int maxSearchHeight = std::min(active_chain.Height(), pMinedBlock->nHeight + activeDuration); int32_t bestBlockHeight = -1; int32_t bestChainlockHeight = -1; - CQuorumCPtr bestSigningQuorum = nullptr; + std::optional bestSigningCommitment = std::nullopt; + uint256 bestSigningMinedBlockHash; std::optional bestChainlockEntry; // Metrics for selection @@ -562,12 +569,21 @@ std::optional CQuorumProofManager::BuildProofChain( // Search window. For performance, if we don't find a known signer quickly, we might limit search. // But finding a known signer is the biggest optimization, so we search aggressively. for (int32_t h = pMinedBlock->nHeight; h <= maxSearchHeight; ++h) { - // Determine signer - CQuorumCPtr signer = DetermineChainlockSigningQuorum(h, active_chain, qman); - if (!signer) continue; - - bool isKnown = knownQuorumPubKeys.count(signer->qc->quorumPublicKey); - int32_t signerHeight = signer->m_quorum_base_block_index->nHeight; + // Determine signer - use optimized method that avoids full quorum construction + auto signerOpt = DetermineChainlockSigningCommitment(h, active_chain, qman); + if (!signerOpt) continue; + + const auto& signer = *signerOpt; + + bool isKnown = knownQuorumPubKeys.count(signer.quorumPublicKey); + + // To get signer height, we need its mined block. + // We use the optimized GetMinedCommitmentBlockHash to avoid full deserialization again. + uint256 signerMinedBlockHash = m_quorum_block_processor.GetMinedCommitmentBlockHash(signer.llmqType, signer.quorumHash); + const CBlockIndex* signerMinedBlockIndex = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(signerMinedBlockHash)); + if (!signerMinedBlockIndex) continue; + + int32_t signerHeight = signerMinedBlockIndex->nHeight; // Optimization: Skip DB lookup if this signer is not interesting // We are interested if: @@ -586,7 +602,8 @@ std::optional CQuorumProofManager::BuildProofChain( // Found a direct bridge! bestBlockHeight = h; bestChainlockHeight = h; // Chainlock is at height h - bestSigningQuorum = signer; + bestSigningCommitment = signer; + bestSigningMinedBlockHash = signerMinedBlockHash; bestChainlockEntry = clEntry; foundKnownSigner = true; break; // Found a known signer, we are done searching for this step. @@ -608,7 +625,8 @@ std::optional CQuorumProofManager::BuildProofChain( if (isBetter) { bestBlockHeight = h; bestChainlockHeight = h; - bestSigningQuorum = signer; + bestSigningCommitment = signer; + bestSigningMinedBlockHash = signerMinedBlockHash; bestChainlockEntry = clEntry; oldestSignerHeight = signerHeight; } @@ -622,10 +640,10 @@ std::optional CQuorumProofManager::BuildProofChain( } const CBlockIndex* pProofBlock = active_chain[bestBlockHeight]; - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Selected proof block %d (mined %d). KnownSigner=%d, SignerHeight=%d\n", - __func__, bestBlockHeight, pMinedBlock->nHeight, foundKnownSigner, bestSigningQuorum->m_quorum_base_block_index->nHeight); + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Selected proof block %d (mined %d). KnownSigner=%d\n", + __func__, bestBlockHeight, pMinedBlock->nHeight, foundKnownSigner); - proofSteps.push_back({currentQuorum, pProofBlock, bestChainlockHeight, bestChainlockEntry}); + proofSteps.push_back({currentCommitment, pProofBlock, bestChainlockHeight, bestChainlockEntry}); if (foundKnownSigner) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Signing quorum's public key is in checkpoint - chain complete!\n", __func__); @@ -633,7 +651,8 @@ std::optional CQuorumProofManager::BuildProofChain( } // Need to prove the signer - currentQuorum = bestSigningQuorum; + currentCommitment = *bestSigningCommitment; + currentMinedBlockHash = bestSigningMinedBlockHash; } // Phase 3: Build proofs in forward order (reverse the dependency chain) @@ -680,7 +699,7 @@ std::optional CQuorumProofManager::BuildProofChain( return std::nullopt; } - auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.quorum->qc->llmqType, step.quorum->qc->quorumHash, &block); + auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.commitment.llmqType, step.commitment.quorumHash, &block); if (!merkleProof.has_value()) { return std::nullopt; } @@ -703,7 +722,7 @@ std::optional CQuorumProofManager::BuildProofChain( } QuorumCommitmentProof commitmentProof; - commitmentProof.commitment = *step.quorum->qc; + commitmentProof.commitment = step.commitment; commitmentProof.chainlockIndex = chainlockIndex; commitmentProof.quorumMerkleProof = merkleProof.value(); commitmentProof.coinbaseTx = block.vtx[0]; @@ -953,4 +972,4 @@ void CQuorumProofManager::MigrateChainlockIndex(const CChain& active_chain, cons blocks_processed, indexed_count); } -} // namespace llmq +} // namespace llmq \ No newline at end of file diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index c0f4444718fb..2663e8d3e493 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -167,7 +167,7 @@ class CQuorumProofManager { const CQuorumBlockProcessor& m_quorum_block_processor; // Helper to determine which quorum signed a chainlock at a given height - [[nodiscard]] CQuorumCPtr DetermineChainlockSigningQuorum( + [[nodiscard]] std::optional DetermineChainlockSigningCommitment( int32_t chainlockHeight, const CChain& active_chain, const CQuorumManager& qman) const; diff --git a/src/llmq/quorumsman.cpp b/src/llmq/quorumsman.cpp index ffcb90878fbf..b56a4a87b95f 100644 --- a/src/llmq/quorumsman.cpp +++ b/src/llmq/quorumsman.cpp @@ -306,6 +306,114 @@ std::vector CQuorumManager::ScanQuorums(Consensus::LLMQType llmqTyp return {vecResultQuorums.begin(), vecResultQuorums.begin() + nResultEndIndex}; } +std::vector CQuorumManager::ScanCommitments(Consensus::LLMQType llmqType, size_t nCountRequested) const +{ + const CBlockIndex* pindex = WITH_LOCK(::cs_main, return m_chainman.ActiveTip()); + return ScanCommitments(llmqType, pindex, nCountRequested); +} + +std::vector CQuorumManager::ScanCommitments(Consensus::LLMQType llmqType, + gsl::not_null pindexStart, + size_t nCountRequested) const +{ + if (nCountRequested == 0 || !m_chainman.IsQuorumTypeEnabled(llmqType, pindexStart)) { + return {}; + } + + gsl::not_null pindexStore{pindexStart}; + const auto& llmq_params_opt = Params().GetLLMQ(llmqType); + assert(llmq_params_opt.has_value()); + + // Quorum sets can only change during the mining phase of DKG. + // Find the closest known block index. + const int quorumCycleStartHeight = pindexStart->nHeight - (pindexStart->nHeight % llmq_params_opt->dkgInterval); + const int quorumCycleMiningStartHeight = quorumCycleStartHeight + llmq_params_opt->dkgMiningWindowStart; + const int quorumCycleMiningEndHeight = quorumCycleStartHeight + llmq_params_opt->dkgMiningWindowEnd; + + if (pindexStart->nHeight < quorumCycleMiningStartHeight) { + // too early for this cycle, use the previous one + // bail out if it's below genesis block + if (quorumCycleMiningEndHeight < llmq_params_opt->dkgInterval) return {}; + pindexStore = pindexStart->GetAncestor(quorumCycleMiningEndHeight - llmq_params_opt->dkgInterval); + } else if (pindexStart->nHeight > quorumCycleMiningEndHeight) { + // we are past the mining phase of this cycle, use it + pindexStore = pindexStart->GetAncestor(quorumCycleMiningEndHeight); + } + // everything else is inside the mining phase of this cycle, no pindexStore adjustment needed + + gsl::not_null pIndexScanCommitments{pindexStore}; + size_t nScanCommitments{nCountRequested}; + std::vector vecResultCommitments; + + { + LOCK(cs_scan_quorums); + if (scanCommitmentsCache.empty()) { + for (const auto& llmq : Params().GetConsensus().llmqs) { + scanCommitmentsCache.try_emplace(llmq.type, llmq.max_cycles(llmq.keepOldConnections) * (llmq.dkgMiningWindowEnd - llmq.dkgMiningWindowStart)); + } + } + auto& cache = scanCommitmentsCache[llmqType]; + bool fCacheExists = cache.get(pindexStore->GetBlockHash(), vecResultCommitments); + if (fCacheExists) { + // We have exactly what requested so just return it + if (vecResultCommitments.size() == nCountRequested) { + return vecResultCommitments; + } + // If we have more cached than requested return only a subvector + if (vecResultCommitments.size() > nCountRequested) { + return {vecResultCommitments.begin(), vecResultCommitments.begin() + nCountRequested}; + } + // If we have cached quorums but not enough, subtract what we have from the count and the set correct index where to start + // scanning for the rests + if (!vecResultCommitments.empty()) { + nScanCommitments -= vecResultCommitments.size(); + // bail out if it's below genesis block + const CBlockIndex* pLastIndex = WITH_LOCK(::cs_main, return m_chainman.m_blockman.LookupBlockIndex(vecResultCommitments.back().quorumHash)); + if (!pLastIndex || pLastIndex->pprev == nullptr) return {}; + pIndexScanCommitments = pLastIndex->pprev; + } + } else { + // If there is nothing in cache request at least keepOldConnections because this gets cached then later + nScanCommitments = std::max(nCountRequested, static_cast(llmq_params_opt->keepOldConnections)); + } + } + + // Get the block indexes of the mined commitments to build the required quorums from + std::vector pQuorumBaseBlockIndexes{ llmq_params_opt->useRotation ? + quorumBlockProcessor.GetMinedCommitmentsIndexedUntilBlock(llmqType, pIndexScanCommitments, nScanCommitments) : + quorumBlockProcessor.GetMinedCommitmentsUntilBlock(llmqType, pIndexScanCommitments, nScanCommitments) + }; + vecResultCommitments.reserve(vecResultCommitments.size() + pQuorumBaseBlockIndexes.size()); + + for (auto& pQuorumBaseBlockIndex : pQuorumBaseBlockIndexes) { + assert(pQuorumBaseBlockIndex); + // We assume that every quorum asked for is available to us on hand, if this + // fails then we can assume that something has gone wrong and we should stop + // trying to process any further and return a blank. + auto [qc, _] = quorumBlockProcessor.GetMinedCommitment(llmqType, pQuorumBaseBlockIndex->GetBlockHash()); + if (qc.IsNull()) { + LogPrintf("%s: ERROR! Unexpected missing commitment with llmqType=%d, blockHash=%s\n", + __func__, ToUnderlying(llmqType), pQuorumBaseBlockIndex->GetBlockHash().ToString()); + return {}; + } + vecResultCommitments.emplace_back(std::move(qc)); + } + + const size_t nCountResult{vecResultCommitments.size()}; + if (nCountResult > 0) { + LOCK(cs_scan_quorums); + // Don't cache more than keepOldConnections elements + // because signing by old quorums requires the exact quorum hash + // to be specified and quorum scanning isn't needed there. + auto& cache = scanCommitmentsCache[llmqType]; + const size_t nCacheEndIndex = std::min(nCountResult, static_cast(llmq_params_opt->keepOldConnections)); + cache.emplace(pindexStore->GetBlockHash(), {vecResultCommitments.begin(), vecResultCommitments.begin() + nCacheEndIndex}); + } + // Don't return more than nCountRequested elements + const size_t nResultEndIndex = std::min(nCountResult, nCountRequested); + return {vecResultCommitments.begin(), vecResultCommitments.begin() + nResultEndIndex}; +} + bool CQuorumManager::IsMasternode() const { if (m_handler) { @@ -750,4 +858,66 @@ VerifyRecSigStatus VerifyRecoveredSig(Consensus::LLMQType llmqType, const CChain const bool ret = sig.VerifyInsecure(quorum->qc->quorumPublicKey, signHash.Get()); return ret ? VerifyRecSigStatus::Valid : VerifyRecSigStatus::Invalid; } + +std::optional SelectCommitmentForSigning(const Consensus::LLMQParams& llmq_params, const CChain& active_chain, const CQuorumManager& qman, + const uint256& selectionHash, int signHeight, int signOffset) +{ + size_t poolSize = llmq_params.signingActiveQuorumCount; + + CBlockIndex* pindexStart; + { + LOCK(::cs_main); + if (signHeight == -1) { + signHeight = active_chain.Height(); + } + int startBlockHeight = signHeight - signOffset; + if (startBlockHeight > active_chain.Height() || startBlockHeight < 0) { + return std::nullopt; + } + pindexStart = active_chain[startBlockHeight]; + } + + if (IsQuorumRotationEnabled(llmq_params, pindexStart)) { + auto commitments = qman.ScanCommitments(llmq_params.type, pindexStart, poolSize); + if (commitments.empty()) { + return std::nullopt; + } + //log2 int + int n = std::log2(llmq_params.signingActiveQuorumCount); + //Extract last 64 bits of selectionHash + uint64_t b = selectionHash.GetUint64(3); + //Take last n bits of b + uint64_t signer = (((1ull << n) - 1) & (b >> (64 - n - 1))); + + if (signer > commitments.size()) { + return std::nullopt; + } + auto it = std::find_if(commitments.begin(), + commitments.end(), + [signer](const CFinalCommitment& obj) { + return uint64_t(obj.quorumIndex) == signer; + }); + if (it == commitments.end()) { + return std::nullopt; + } + return *it; + } else { + auto commitments = qman.ScanCommitments(llmq_params.type, pindexStart, poolSize); + if (commitments.empty()) { + return std::nullopt; + } + + std::vector> scores; + scores.reserve(commitments.size()); + for (const auto i : irange::range(commitments.size())) { + CHashWriter h(SER_NETWORK, 0); + h << llmq_params.type; + h << commitments[i].quorumHash; + h << selectionHash; + scores.emplace_back(h.GetHash(), i); + } + std::sort(scores.begin(), scores.end()); + return commitments[scores.front().second]; + } +} } // namespace llmq diff --git a/src/llmq/quorumsman.h b/src/llmq/quorumsman.h index 98fdaf5f6c65..ea42b0953d9d 100644 --- a/src/llmq/quorumsman.h +++ b/src/llmq/quorumsman.h @@ -89,6 +89,8 @@ class CQuorumManager final : public QuorumObserverParent mutable Mutex cs_scan_quorums; // TODO: merge cs_map_quorums, cs_scan_quorums mutexes mutable std::map>> scanQuorumsCache GUARDED_BY(cs_scan_quorums); + mutable std::map>> scanCommitmentsCache + GUARDED_BY(cs_scan_quorums); // On mainnet, we have around 62 quorums active at any point; let's cache a little more than double that to be safe. // it maps `quorum_hash` to `pindex` @@ -149,6 +151,12 @@ class CQuorumManager final : public QuorumObserverParent size_t nCountRequested) const override EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !cs_map_quorums, !cs_scan_quorums, !m_cache_cs); + std::vector ScanCommitments(Consensus::LLMQType llmqType, size_t nCountRequested) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !cs_map_quorums, !cs_scan_quorums, !m_cache_cs); + std::vector ScanCommitments(Consensus::LLMQType llmqType, gsl::not_null pindexStart, + size_t nCountRequested) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !cs_map_quorums, !cs_scan_quorums, !m_cache_cs); + bool IsMasternode() const; bool IsWatching() const; @@ -186,6 +194,9 @@ static constexpr int SIGN_HEIGHT_OFFSET{8}; CQuorumCPtr SelectQuorumForSigning(const Consensus::LLMQParams& llmq_params, const CChain& active_chain, const CQuorumManager& qman, const uint256& selectionHash, int signHeight = -1 /*chain tip*/, int signOffset = SIGN_HEIGHT_OFFSET); +std::optional SelectCommitmentForSigning(const Consensus::LLMQParams& llmq_params, const CChain& active_chain, const CQuorumManager& qman, + const uint256& selectionHash, int signHeight = -1 /*chain tip*/, int signOffset = SIGN_HEIGHT_OFFSET); + // Verifies a recovered sig that was signed while the chain tip was at signedAtTip VerifyRecSigStatus VerifyRecoveredSig(Consensus::LLMQType llmqType, const CChain& active_chain, const CQuorumManager& qman, int signedAtHeight, const uint256& id, const uint256& msgHash, const CBLSSignature& sig, From c641655bbc8c90de0ce471a1adaf292d8c5064a5 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 16:56:22 -0600 Subject: [PATCH 08/12] perf(llmq): optimize quorum proof chain generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce proof chain generation time by 90%+ for long chains through: 1. CachedCommitmentInfo struct: Lightweight struct containing only the fields needed for signer selection (quorumHash, publicKey, quorumIndex, llmqType, pMinedBlock), avoiding repeated full CFinalCommitment deserialization. 2. ComputeSigningCommitmentIndex function: Computes commitment selection using cached data without any database reads, replacing repeated calls to DetermineChainlockSigningCommitment -> SelectCommitmentForSigning -> ScanCommitments. 3. Per-step commitment caching: Fetches active commitments once at the start of each proof step's search window instead of per height, reducing DB reads from O(heights × commitments) to O(commitments). 4. CommitmentHashCache: Cross-step caching of commitment hashes in BuildQuorumMerkleProof, avoiding repeated GetMinedCommitmentTxHash calls for commitments shared between consecutive proof steps. Benchmark results vs baseline: - 30 hours: 0.282s -> 0.136s (52% faster) - 7 days: 0.495s -> 0.159s (68% faster) - 30 days: 2.304s -> 0.203s (91% faster) - 6 months: 10.083s -> 0.572s (94% faster) - 12 months: 15.286s -> 1.090s (93% faster) Co-Authored-By: Claude Opus 4.5 --- src/llmq/quorumproofs.cpp | 225 +++++++++++++++++++++++++++----------- src/llmq/quorumproofs.h | 7 +- 2 files changed, 169 insertions(+), 63 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index f0fef2bf0641..f216a60412e7 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -29,6 +29,62 @@ using node::ReadBlockFromDisk; namespace llmq { +/** + * Lightweight commitment info for proof chain building. + * Avoids repeated full CFinalCommitment deserialization by caching only the data we need. + */ +struct CachedCommitmentInfo { + uint256 quorumHash; + CBLSPublicKey publicKey; + const CBlockIndex* pMinedBlock; + uint16_t quorumIndex; + Consensus::LLMQType llmqType; +}; + +/** + * Compute which commitment would sign a given height using cached commitment data. + * This is a lightweight version of SelectCommitmentForSigning that avoids DB reads. + * + * @param llmq_params The LLMQ parameters + * @param commitments Cached commitment info (must be non-empty) + * @param selectionHash The request ID for the chainlock (GenSigRequestId(height)) + * @return Index into commitments vector of the selected quorum + */ +static size_t ComputeSigningCommitmentIndex( + const Consensus::LLMQParams& llmq_params, + const std::vector& commitments, + const uint256& selectionHash) +{ + assert(!commitments.empty()); + + if (llmq_params.useRotation) { + // For rotated quorums, selection is based on quorumIndex + int n = std::log2(llmq_params.signingActiveQuorumCount); + uint64_t b = selectionHash.GetUint64(3); + uint64_t signer = (((1ull << n) - 1) & (b >> (64 - n - 1))); + + for (size_t i = 0; i < commitments.size(); ++i) { + if (static_cast(commitments[i].quorumIndex) == signer) { + return i; + } + } + return 0; // Fallback to first if not found + } else { + // For non-rotated quorums, selection is based on hash score + std::vector> scores; + scores.reserve(commitments.size()); + for (size_t i = 0; i < commitments.size(); ++i) { + CHashWriter h(SER_NETWORK, 0); + h << llmq_params.type; + h << commitments[i].quorumHash; + h << selectionHash; + scores.emplace_back(h.GetHash(), i); + } + std::sort(scores.begin(), scores.end()); + return scores.front().second; + } +} + // // JSON Serialization helpers // @@ -318,7 +374,8 @@ std::optional CQuorumProofManager::BuildQuorumMerkleProof( const CBlockIndex* pindex, Consensus::LLMQType llmqType, const uint256& quorumHash, - const CBlock* pBlock) const + const CBlock* pBlock, + CommitmentHashCache* pHashCache) const { if (pindex == nullptr || pindex->pprev == nullptr) { return std::nullopt; @@ -335,17 +392,35 @@ std::optional CQuorumProofManager::BuildQuorumMerkleProof( for (const auto& [type, blockIndexes] : commitmentsMap) { for (const auto* blockIndex : blockIndexes) { - // Optimization: Get hash directly without deserialization - // The mined commitment's quorumHash is the blockHash of the blockIndex - uint256 commitmentHash = m_quorum_block_processor.GetMinedCommitmentTxHash(type, blockIndex->GetBlockHash()); - + const uint256& blockHash = blockIndex->GetBlockHash(); + auto cacheKey = std::make_pair(type, blockHash); + + uint256 commitmentHash; + + // Check cache first to avoid DB reads + if (pHashCache) { + auto it = pHashCache->find(cacheKey); + if (it != pHashCache->end()) { + commitmentHash = it->second; + } else { + // Cache miss - fetch from DB and cache + commitmentHash = m_quorum_block_processor.GetMinedCommitmentTxHash(type, blockHash); + if (commitmentHash != uint256::ZERO) { + pHashCache->emplace(cacheKey, commitmentHash); + } + } + } else { + // No cache provided - fetch directly + commitmentHash = m_quorum_block_processor.GetMinedCommitmentTxHash(type, blockHash); + } + if (commitmentHash == uint256::ZERO) { continue; } commitmentHashes.push_back(commitmentHash); - if (type == llmqType && blockIndex->GetBlockHash() == quorumHash) { + if (type == llmqType && blockHash == quorumHash) { targetCommitmentHash = commitmentHash; targetFound = true; } @@ -548,85 +623,100 @@ std::optional CQuorumProofManager::BuildProofChain( // We can use ANY block where the quorum is active, not just the mined block. // We prioritize: // 1. Blocks signed by a KNOWN quorum (direct bridge) - // 2. Blocks that are NOT superblocks (small proof size) - // 3. Blocks signed by the oldest possible quorum (maximize jump) + // 2. Blocks signed by the oldest possible quorum (maximize jump) - const auto llmq_params = Params().GetLLMQ(currentCommitment.llmqType).value(); + const auto& llmq_params_opt = Params().GetLLMQ(Params().GetConsensus().llmqTypeChainLocks); + assert(llmq_params_opt.has_value()); + const auto& llmq_params = llmq_params_opt.value(); // Quorum is active for signingActiveQuorumCount * dkgInterval blocks int activeDuration = std::min(llmq_params.signingActiveQuorumCount * llmq_params.dkgInterval, 100); int maxSearchHeight = std::min(active_chain.Height(), pMinedBlock->nHeight + activeDuration); int32_t bestBlockHeight = -1; int32_t bestChainlockHeight = -1; - std::optional bestSigningCommitment = std::nullopt; - uint256 bestSigningMinedBlockHash; + size_t bestSignerIndex = 0; std::optional bestChainlockEntry; // Metrics for selection bool foundKnownSigner = false; int32_t oldestSignerHeight = std::numeric_limits::max(); - // Search window. For performance, if we don't find a known signer quickly, we might limit search. - // But finding a known signer is the biggest optimization, so we search aggressively. + // PERFORMANCE OPTIMIZATION: Fetch active commitments ONCE and cache them + // This avoids repeated calls to ScanCommitments and GetMinedCommitment for each height + std::vector cachedCommitments; + { + // Get commitments at the start of the search window + int refHeight = pMinedBlock->nHeight - SIGN_HEIGHT_OFFSET; + if (refHeight < 0) refHeight = 0; + const CBlockIndex* pRefIndex = active_chain[refHeight]; + if (!pRefIndex) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not get reference block at height %d\n", + __func__, refHeight); + return std::nullopt; + } + + // Fetch commitments once + auto commitments = qman.ScanCommitments(llmq_params.type, pRefIndex, llmq_params.signingActiveQuorumCount); + if (commitments.empty()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No active commitments found at height %d\n", + __func__, refHeight); + return std::nullopt; + } + + // Build cached info with mined block pointers + cachedCommitments.reserve(commitments.size()); + for (const auto& qc : commitments) { + CachedCommitmentInfo info; + info.quorumHash = qc.quorumHash; + info.publicKey = qc.quorumPublicKey; + info.quorumIndex = qc.quorumIndex; + info.llmqType = qc.llmqType; + + // Get mined block (single DB read per commitment, not per height) + uint256 minedBlockHash = m_quorum_block_processor.GetMinedCommitmentBlockHash(qc.llmqType, qc.quorumHash); + info.pMinedBlock = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(minedBlockHash)); + if (!info.pMinedBlock) continue; + cachedCommitments.push_back(std::move(info)); + } + + if (cachedCommitments.empty()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not resolve mined blocks for commitments\n", __func__); + return std::nullopt; + } + } + + // Search window using cached data - no DB reads in the inner loop for (int32_t h = pMinedBlock->nHeight; h <= maxSearchHeight; ++h) { - // Determine signer - use optimized method that avoids full quorum construction - auto signerOpt = DetermineChainlockSigningCommitment(h, active_chain, qman); - if (!signerOpt) continue; - - const auto& signer = *signerOpt; - - bool isKnown = knownQuorumPubKeys.count(signer.quorumPublicKey); - - // To get signer height, we need its mined block. - // We use the optimized GetMinedCommitmentBlockHash to avoid full deserialization again. - uint256 signerMinedBlockHash = m_quorum_block_processor.GetMinedCommitmentBlockHash(signer.llmqType, signer.quorumHash); - const CBlockIndex* signerMinedBlockIndex = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(signerMinedBlockHash)); - if (!signerMinedBlockIndex) continue; - - int32_t signerHeight = signerMinedBlockIndex->nHeight; - - // Optimization: Skip DB lookup if this signer is not interesting - // We are interested if: - // 1. It's a known signer (direct bridge) - // 2. We haven't found any candidate yet - // 3. It's strictly better (older) than our current best candidate - bool isInteresting = isKnown || bestBlockHeight == -1 || signerHeight < oldestSignerHeight; + // Compute which commitment would sign this height using cached data + const uint256 requestId = chainlock::GenSigRequestId(h); + size_t signerIdx = ComputeSigningCommitmentIndex(llmq_params, cachedCommitments, requestId); + const auto& signer = cachedCommitments[signerIdx]; + + bool isKnown = knownQuorumPubKeys.count(signer.publicKey); + int32_t signerHeight = signer.pMinedBlock->nHeight; + // Skip if this signer is not interesting + bool isInteresting = isKnown || bestBlockHeight == -1 || signerHeight < oldestSignerHeight; if (!isInteresting) continue; - // Get chainlock for this height + // Get chainlock for this height (DB read, but only for interesting heights) auto clEntry = GetChainlockByHeight(h); if (!clEntry.has_value()) continue; if (isKnown) { // Found a direct bridge! bestBlockHeight = h; - bestChainlockHeight = h; // Chainlock is at height h - bestSigningCommitment = signer; - bestSigningMinedBlockHash = signerMinedBlockHash; + bestChainlockHeight = h; + bestSignerIndex = signerIdx; bestChainlockEntry = clEntry; foundKnownSigner = true; - break; // Found a known signer, we are done searching for this step. - } else if (!foundKnownSigner) { - // Not known. We want the one that maximizes the jump back. - // The jump size is determined by the signer's creation height. - // We want the signer with the lowest creation height (oldest). - - bool isBetter = false; - if (bestBlockHeight == -1) { - isBetter = true; - } else { - // Pick oldest signer - if (signerHeight < oldestSignerHeight) { - isBetter = true; - } - } - - if (isBetter) { + break; + } else { + // Not known. Pick the oldest signer to maximize the jump back. + if (bestBlockHeight == -1 || signerHeight < oldestSignerHeight) { bestBlockHeight = h; bestChainlockHeight = h; - bestSigningCommitment = signer; - bestSigningMinedBlockHash = signerMinedBlockHash; + bestSignerIndex = signerIdx; bestChainlockEntry = clEntry; oldestSignerHeight = signerHeight; } @@ -650,9 +740,16 @@ std::optional CQuorumProofManager::BuildProofChain( break; } - // Need to prove the signer - currentCommitment = *bestSigningCommitment; - currentMinedBlockHash = bestSigningMinedBlockHash; + // Need to prove the signer - fetch full commitment now (only one DB read per step) + const auto& bestSigner = cachedCommitments[bestSignerIndex]; + auto [signerCommitment, signerMinedHash] = m_quorum_block_processor.GetMinedCommitment(bestSigner.llmqType, bestSigner.quorumHash); + if (signerCommitment.IsNull()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not fetch commitment for signer %s\n", + __func__, bestSigner.quorumHash.ToString()); + return std::nullopt; + } + currentCommitment = std::move(signerCommitment); + currentMinedBlockHash = signerMinedHash; } // Phase 3: Build proofs in forward order (reverse the dependency chain) @@ -662,6 +759,10 @@ std::optional CQuorumProofManager::BuildProofChain( QuorumProofChain chain; std::set includedChainlockHeights; + // Cache commitment hashes across proof steps to avoid repeated DB reads + // Consecutive steps often share many of the same active commitments + CommitmentHashCache commitmentHashCache; + for (const auto& step : proofSteps) { // Add chainlock entry if not already included if (!includedChainlockHeights.count(step.chainlockHeight)) { @@ -699,7 +800,7 @@ std::optional CQuorumProofManager::BuildProofChain( return std::nullopt; } - auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.commitment.llmqType, step.commitment.quorumHash, &block); + auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.commitment.llmqType, step.commitment.quorumHash, &block, &commitmentHashCache); if (!merkleProof.has_value()) { return std::nullopt; } diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index 2663e8d3e493..e610f068dc29 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -158,6 +158,10 @@ struct QuorumProofVerifyResult { [[nodiscard]] UniValue ToJson() const; }; +// Type alias for commitment hash cache used in proof building +// Maps (llmqType, quorumHash) -> SerializeHash(commitment) +using CommitmentHashCache = std::map, uint256>; + /** * Manager for chainlock indexing and quorum proof generation/verification. */ @@ -203,7 +207,8 @@ class CQuorumProofManager { const CBlockIndex* pindex, Consensus::LLMQType llmqType, const uint256& quorumHash, - const CBlock* pBlock = nullptr) const; + const CBlock* pBlock = nullptr, + CommitmentHashCache* pHashCache = nullptr) const; // Proof Chain Generation [[nodiscard]] std::optional BuildProofChain( From 196f96a18f5a78b3371637b6eabf4da622ec710c Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 17:10:56 -0600 Subject: [PATCH 09/12] refactor(llmq): use QuorumMerkleProof::Verify instead of local static function Co-Authored-By: Claude Opus 4.5 --- src/llmq/quorumproofs.cpp | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index f216a60412e7..a69869338636 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -279,39 +279,6 @@ std::optional CQuorumProofManager::GetChainlockByHeight(int return std::nullopt; } -/** - * Verify a merkle proof by computing the root from a leaf hash and comparing to expected. - * @param leafHash The hash of the leaf element - * @param merklePath The sibling hashes from leaf to root - * @param merklePathSide Side indicators (true = sibling on right, false = sibling on left) - * @param expectedRoot The expected merkle root - * @return true if proof is valid - */ -static bool VerifyMerkleProof(const uint256& leafHash, - const std::vector& merklePath, - const std::vector& merklePathSide, - const uint256& expectedRoot) -{ - if (merklePath.size() != merklePathSide.size()) { - return false; - } - - if (merklePath.size() > MAX_MERKLE_PATH_LENGTH) { - return false; - } - - uint256 current = leafHash; - for (size_t i = 0; i < merklePath.size(); ++i) { - if (merklePathSide[i]) { - current = Hash(current, merklePath[i]); - } else { - current = Hash(merklePath[i], current); - } - } - - return current == expectedRoot; -} - /** * Helper function to build merkle proof with path tracking. * Returns the merkle path (sibling hashes) and side indicators. @@ -930,8 +897,8 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( } const uint256 coinbaseTxHash = qProof.coinbaseTx->GetHash(); - if (!VerifyMerkleProof(coinbaseTxHash, qProof.coinbaseMerklePath, - qProof.coinbaseMerklePathSide, header.hashMerkleRoot)) { + QuorumMerkleProof coinbaseMerkleProof{qProof.coinbaseMerklePath, qProof.coinbaseMerklePathSide}; + if (!coinbaseMerkleProof.Verify(coinbaseTxHash, header.hashMerkleRoot)) { result.error = strprintf("Coinbase merkle proof verification failed in proof %d", proofIdx); return result; } From 243dcc04a5bdaef1af4e37ce65896021366f466a Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 17:44:37 -0600 Subject: [PATCH 10/12] perf(llmq): add quorum proof data caching for faster proof chain generation Add a database index (DB_QUORUM_PROOF_DATA) that stores pre-computed proof components for each quorum commitment: - Merkle proof within merkleRootQuorums - Coinbase transaction and merkle proof - Block header This avoids expensive ReadBlockFromDisk() and merkle proof computation at query time by: - Storing proof data when commitments are mined (ProcessBlock) - Removing proof data on reorg (UndoBlock) - Migrating historical commitments on first startup BuildProofChain() now uses the cached data when available, falling back to on-the-fly computation for backwards compatibility. Performance improvement: ~20% reduction in proof generation time (1.13s -> 0.89s for 12-month range). Further optimization of Phase 2 (chain building) is needed for larger gains. Co-Authored-By: Claude Opus 4.5 --- src/init.cpp | 3 + src/llmq/blockprocessor.cpp | 122 +++++++++++++++++++ src/llmq/quorumproofs.cpp | 236 +++++++++++++++++++++++++++++++----- src/llmq/quorumproofs.h | 48 ++++++++ 4 files changed, 377 insertions(+), 32 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index bf976a709d83..3beea3ad1c73 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2176,6 +2176,9 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) if (node.llmq_ctx && node.llmq_ctx->quorum_proof_manager) { LOCK(cs_main); node.llmq_ctx->quorum_proof_manager->MigrateChainlockIndex(chainman.ActiveChain(), chainparams); + // Migrate quorum proof data index for fast proof chain generation + node.llmq_ctx->quorum_proof_manager->MigrateQuorumProofIndex(chainman.ActiveChain(), chainparams, + chainman.m_blockman); } assert(!node.dstxman); diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 28f8bace3504..5938c5951588 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -163,6 +164,52 @@ MessageProcessingResult CQuorumBlockProcessor::ProcessMessage(const CNode& peer, return ret; } +/** + * Helper function to build merkle proof with path tracking. + * Returns the merkle path (sibling hashes) and side indicators. + */ +static std::pair, std::vector> BuildMerkleProofPath( + const std::vector& hashes, size_t targetIndex) +{ + std::vector merklePath; + std::vector merklePathSide; + + if (hashes.empty()) { + return {merklePath, merklePathSide}; + } + + std::vector current = hashes; + size_t index = targetIndex; + + while (current.size() > 1) { + std::vector next; + size_t nextIndex = 0; + + for (size_t i = 0; i < current.size(); i += 2) { + size_t left = i; + size_t right = (i + 1 < current.size()) ? i + 1 : i; + + if (index == left || index == right) { + if (index == left) { + merklePath.push_back(current[right]); + merklePathSide.push_back(true); + } else { + merklePath.push_back(current[left]); + merklePathSide.push_back(false); + } + nextIndex = next.size(); + } + + next.push_back(Hash(current[left], current[right])); + } + + index = nextIndex; + current = std::move(next); + } + + return {merklePath, merklePathSide}; +} + bool CQuorumBlockProcessor::ProcessBlock(const CBlock& block, gsl::not_null pindex, BlockValidationState& state, bool fJustCheck, bool fBLSChecks) { AssertLockHeld(::cs_main); @@ -231,6 +278,78 @@ bool CQuorumBlockProcessor::ProcessBlock(const CBlock& block, gsl::not_nullpprev); + + // Collect all commitment hashes for merkle root calculation + std::vector allCommitmentHashes; + for (const auto& [type, blockIndexes] : commitmentsMap) { + for (const auto* blockIndex : blockIndexes) { + uint256 commitmentHash = GetMinedCommitmentTxHash(type, blockIndex->GetBlockHash()); + if (commitmentHash != uint256::ZERO) { + allCommitmentHashes.push_back(commitmentHash); + } + } + } + + // Add commitments from current block + for (size_t i = 1; i < block.vtx.size(); ++i) { + const auto& tx = block.vtx[i]; + if (tx->IsSpecialTxVersion() && tx->nType == TRANSACTION_QUORUM_COMMITMENT) { + const auto opt_qc = GetTxPayload(*tx); + if (opt_qc && !opt_qc->commitment.IsNull()) { + allCommitmentHashes.push_back(::SerializeHash(opt_qc->commitment)); + } + } + } + + // Sort to match CalcCbTxMerkleRootQuorums + std::sort(allCommitmentHashes.begin(), allCommitmentHashes.end()); + + // Build coinbase merkle proof (same for all commitments in this block) + std::vector txHashes; + txHashes.reserve(block.vtx.size()); + for (const auto& tx : block.vtx) { + txHashes.push_back(tx->GetHash()); + } + auto [cbPath, cbSide] = BuildMerkleProofPath(txHashes, 0); + + // Store proof data for each non-null commitment in this block + for (const auto& [type, qc] : qcs) { + if (qc.IsNull()) continue; + + // Find commitment hash in sorted list + uint256 targetHash = ::SerializeHash(qc); + auto it = std::find(allCommitmentHashes.begin(), allCommitmentHashes.end(), targetHash); + if (it == allCommitmentHashes.end()) { + LogPrint(BCLog::LLMQ, "[ProcessBlock] Could not find commitment hash for %s in active set\n", + qc.quorumHash.ToString()); + continue; + } + size_t targetIndex = std::distance(allCommitmentHashes.begin(), it); + + // Build quorum merkle proof + auto [qPath, qSide] = BuildMerkleProofPath(allCommitmentHashes, targetIndex); + + // Store proof data + QuorumProofData proofData; + proofData.quorumMerkleProof.merklePath = std::move(qPath); + proofData.quorumMerkleProof.merklePathSide = std::move(qSide); + proofData.coinbaseTx = block.vtx[0]; + proofData.coinbaseMerklePath = cbPath; // Copy since reused + proofData.coinbaseMerklePathSide = cbSide; + proofData.header = block.GetBlockHeader(); + + auto proofKey = std::make_pair(DB_QUORUM_PROOF_DATA, std::make_pair(qc.llmqType, qc.quorumHash)); + m_evoDb.Write(proofKey, proofData); + + LogPrint(BCLog::LLMQ, "[ProcessBlock] Stored proof data for quorum %s type=%d\n", + qc.quorumHash.ToString(), ToUnderlying(qc.llmqType)); + } + } + m_evoDb.Write(DB_BEST_BLOCK_UPGRADE, blockHash); return true; @@ -400,6 +519,9 @@ bool CQuorumBlockProcessor::UndoBlock(const CBlock& block, gsl::not_null CQuorumProofManager::BuildProofChain( includedChainlockHeights.insert(step.chainlockHeight); } - // Build the quorum commitment proof - use the MINED block where the commitment is - const CBlockIndex* pMinedBlock = step.pMinedBlockIndex; - - // Read the block to get coinbase transaction - CBlock block; - if (!ReadBlockFromDisk(block, pMinedBlock, Params().GetConsensus())) { - return std::nullopt; - } - - auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.commitment.llmqType, step.commitment.quorumHash, &block, &commitmentHashCache); - if (!merkleProof.has_value()) { - return std::nullopt; - } - - // Build coinbase merkle proof - std::vector txHashes; - for (const auto& tx : block.vtx) { - txHashes.push_back(tx->GetHash()); - } - - auto [cbPath, cbSide] = BuildMerkleProofPath(txHashes, 0); // Coinbase is at index 0 - // Find the chainlock index for this proof step uint32_t chainlockIndex = 0; for (size_t i = 0; i < chain.chainlocks.size(); ++i) { @@ -789,18 +767,58 @@ std::optional CQuorumProofManager::BuildProofChain( } } - QuorumCommitmentProof commitmentProof; - commitmentProof.commitment = step.commitment; - commitmentProof.chainlockIndex = chainlockIndex; - commitmentProof.quorumMerkleProof = merkleProof.value(); - commitmentProof.coinbaseTx = block.vtx[0]; - commitmentProof.coinbaseMerklePath = std::move(cbPath); - commitmentProof.coinbaseMerklePathSide = std::move(cbSide); + // Try to get pre-computed proof data from the index (fast path) + auto proofData = GetQuorumProofData(step.commitment.llmqType, step.commitment.quorumHash); + + if (proofData.has_value()) { + // Use pre-computed proof data + QuorumCommitmentProof commitmentProof; + commitmentProof.commitment = step.commitment; + commitmentProof.chainlockIndex = chainlockIndex; + commitmentProof.quorumMerkleProof = proofData->quorumMerkleProof; + commitmentProof.coinbaseTx = proofData->coinbaseTx; + commitmentProof.coinbaseMerklePath = proofData->coinbaseMerklePath; + commitmentProof.coinbaseMerklePathSide = proofData->coinbaseMerklePathSide; + + chain.quorumProofs.push_back(commitmentProof); + chain.headers.push_back(proofData->header); + } else { + // Fallback: compute proof data on-the-fly (slow path - for backwards compatibility) + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No cached proof data for quorum %s, computing on-the-fly\n", + __func__, step.commitment.quorumHash.ToString()); - chain.quorumProofs.push_back(commitmentProof); + const CBlockIndex* pMinedBlock = step.pMinedBlockIndex; - // Add the block header - chain.headers.push_back(block.GetBlockHeader()); + // Read the block to get coinbase transaction + CBlock block; + if (!ReadBlockFromDisk(block, pMinedBlock, Params().GetConsensus())) { + return std::nullopt; + } + + auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.commitment.llmqType, step.commitment.quorumHash, &block, &commitmentHashCache); + if (!merkleProof.has_value()) { + return std::nullopt; + } + + // Build coinbase merkle proof + std::vector txHashes; + for (const auto& tx : block.vtx) { + txHashes.push_back(tx->GetHash()); + } + + auto [cbPath, cbSide] = BuildMerkleProofPath(txHashes, 0); // Coinbase is at index 0 + + QuorumCommitmentProof commitmentProof; + commitmentProof.commitment = step.commitment; + commitmentProof.chainlockIndex = chainlockIndex; + commitmentProof.quorumMerkleProof = merkleProof.value(); + commitmentProof.coinbaseTx = block.vtx[0]; + commitmentProof.coinbaseMerklePath = std::move(cbPath); + commitmentProof.coinbaseMerklePathSide = std::move(cbSide); + + chain.quorumProofs.push_back(commitmentProof); + chain.headers.push_back(block.GetBlockHeader()); + } } return chain; @@ -1040,4 +1058,158 @@ void CQuorumProofManager::MigrateChainlockIndex(const CChain& active_chain, cons blocks_processed, indexed_count); } +void CQuorumProofManager::StoreQuorumProofData(Consensus::LLMQType llmqType, const uint256& quorumHash, + const QuorumProofData& proofData) +{ + auto key = std::make_pair(DB_QUORUM_PROOF_DATA, std::make_pair(llmqType, quorumHash)); + m_evoDb.Write(key, proofData); +} + +void CQuorumProofManager::EraseQuorumProofData(Consensus::LLMQType llmqType, const uint256& quorumHash) +{ + auto key = std::make_pair(DB_QUORUM_PROOF_DATA, std::make_pair(llmqType, quorumHash)); + m_evoDb.Erase(key); +} + +std::optional CQuorumProofManager::GetQuorumProofData(Consensus::LLMQType llmqType, + const uint256& quorumHash) const +{ + auto key = std::make_pair(DB_QUORUM_PROOF_DATA, std::make_pair(llmqType, quorumHash)); + QuorumProofData proofData; + if (m_evoDb.Read(key, proofData)) { + return proofData; + } + return std::nullopt; +} + +std::optional CQuorumProofManager::ComputeQuorumProofData( + const CBlockIndex* pMinedBlock, + Consensus::LLMQType llmqType, + const uint256& quorumHash, + const CBlock& block) const +{ + if (pMinedBlock == nullptr) { + return std::nullopt; + } + + // Build the quorum merkle proof + auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, llmqType, quorumHash, &block); + if (!merkleProof.has_value()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Failed to build quorum merkle proof for %s\n", + __func__, quorumHash.ToString()); + return std::nullopt; + } + + // Build the coinbase merkle proof + std::vector txHashes; + txHashes.reserve(block.vtx.size()); + for (const auto& tx : block.vtx) { + txHashes.push_back(tx->GetHash()); + } + auto [cbPath, cbSide] = BuildMerkleProofPath(txHashes, 0); // Coinbase is at index 0 + + QuorumProofData proofData; + proofData.quorumMerkleProof = merkleProof.value(); + proofData.coinbaseTx = block.vtx[0]; + proofData.coinbaseMerklePath = std::move(cbPath); + proofData.coinbaseMerklePathSide = std::move(cbSide); + proofData.header = block.GetBlockHeader(); + + return proofData; +} + +void CQuorumProofManager::MigrateQuorumProofIndex(const CChain& active_chain, const CChainParams& chainparams, + const node::BlockManager& block_man) +{ + // Check if migration is needed + int version{0}; + if (m_evoDb.Read(DB_QUORUM_PROOF_INDEX_VERSION, version) && version >= QUORUM_PROOF_INDEX_VERSION) { + LogPrintf("CQuorumProofManager: Quorum proof index is up to date (version %d)\n", version); + return; + } + + LogPrintf("CQuorumProofManager: Building quorum proof index from historical commitments...\n"); + + // Show initial progress in UI + uiInterface.ShowProgress(_("Building quorum proof index…").translated, 0, false); + + int indexed_count = 0; + int skipped_count = 0; + + // Iterate through ALL LLMQ types that have mined commitments + for (const auto& llmq_params : chainparams.GetConsensus().llmqs) { + const auto llmqType = llmq_params.type; + LogPrintf("CQuorumProofManager: Processing LLMQ type %d...\n", static_cast(llmqType)); + + // Use GetMinedCommitmentsUntilBlock to get all commitments up to the tip + // We use a large maxCount to get all of them + const CBlockIndex* pTip = active_chain.Tip(); + if (!pTip) continue; + + auto commitmentIndexes = m_quorum_block_processor.GetMinedCommitmentsUntilBlock(llmqType, pTip, 100000); + + for (const CBlockIndex* pQuorumBaseBlockIndex : commitmentIndexes) { + const uint256& quorumHash = pQuorumBaseBlockIndex->GetBlockHash(); + + // Check if proof data already exists (skip if so) + auto proofKey = std::make_pair(DB_QUORUM_PROOF_DATA, std::make_pair(llmqType, quorumHash)); + if (m_evoDb.Exists(proofKey)) { + skipped_count++; + continue; + } + + // Get the mined commitment and the block where it was mined + auto [qc, minedBlockHash] = m_quorum_block_processor.GetMinedCommitment(llmqType, quorumHash); + if (qc.IsNull()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Commitment not found for %s\n", + __func__, quorumHash.ToString()); + continue; + } + + // Get the mined block index + const CBlockIndex* pMinedBlock = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(minedBlockHash)); + if (!pMinedBlock) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Mined block %s not found for quorum %s\n", + __func__, minedBlockHash.ToString(), quorumHash.ToString()); + continue; + } + + // Read block from disk + CBlock block; + if (!ReadBlockFromDisk(block, pMinedBlock, chainparams.GetConsensus())) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Failed to read block %s from disk\n", + __func__, minedBlockHash.ToString()); + continue; + } + + // Compute and store proof data + auto proofData = ComputeQuorumProofData(pMinedBlock, llmqType, quorumHash, block); + if (!proofData.has_value()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Failed to compute proof data for %s\n", + __func__, quorumHash.ToString()); + continue; + } + + StoreQuorumProofData(llmqType, quorumHash, proofData.value()); + indexed_count++; + + // Update progress periodically + if (indexed_count % 100 == 0) { + int percentageDone = std::max(1, std::min(99, indexed_count / 10)); // Rough estimate + uiInterface.ShowProgress(_("Building quorum proof index…").translated, percentageDone, false); + LogPrintf("CQuorumProofManager: Migration progress: %d quorums indexed\n", indexed_count); + } + } + } + + // Write version to mark migration complete + m_evoDb.Write(DB_QUORUM_PROOF_INDEX_VERSION, QUORUM_PROOF_INDEX_VERSION); + + // Hide progress indicator + uiInterface.ShowProgress("", 100, false); + + LogPrintf("CQuorumProofManager: Quorum proof index migration complete. Indexed %d quorums, skipped %d (already indexed)\n", + indexed_count, skipped_count); +} + } // namespace llmq \ No newline at end of file diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index e610f068dc29..bb4c93b9fdc5 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -162,6 +162,25 @@ struct QuorumProofVerifyResult { // Maps (llmqType, quorumHash) -> SerializeHash(commitment) using CommitmentHashCache = std::map, uint256>; +/** + * Pre-computed proof data for a quorum commitment. + * Stored in DB when a commitment is mined and retrieved for proof chain generation. + * This avoids expensive disk reads and merkle proof computations at query time. + */ +struct QuorumProofData { + QuorumMerkleProof quorumMerkleProof; // Proof within merkleRootQuorums + CTransactionRef coinbaseTx; // The coinbase transaction containing merkleRootQuorums + std::vector coinbaseMerklePath; // Proof that coinbaseTx is in block's merkle root + std::vector coinbaseMerklePathSide; // Side indicators for coinbase merkle proof + CBlockHeader header; // Block header where quorum was mined + + SERIALIZE_METHODS(QuorumProofData, obj) { + READWRITE(obj.quorumMerkleProof, obj.coinbaseTx, + obj.coinbaseMerklePath, DYNBITSET(obj.coinbaseMerklePathSide), + obj.header); + } +}; + /** * Manager for chainlock indexing and quorum proof generation/verification. */ @@ -229,6 +248,25 @@ class CQuorumProofManager { // Migration: Build chainlock index from historical blocks // Should be called once during startup after chain is loaded void MigrateChainlockIndex(const CChain& active_chain, const CChainParams& chainparams); + + // Migration: Build quorum proof data index from historical commitments + // Should be called once during startup after chain is loaded + void MigrateQuorumProofIndex(const CChain& active_chain, const CChainParams& chainparams, + const node::BlockManager& block_man); + + // Quorum Proof Data Index Management + void StoreQuorumProofData(Consensus::LLMQType llmqType, const uint256& quorumHash, + const QuorumProofData& proofData); + void EraseQuorumProofData(Consensus::LLMQType llmqType, const uint256& quorumHash); + [[nodiscard]] std::optional GetQuorumProofData(Consensus::LLMQType llmqType, + const uint256& quorumHash) const; + + // Helper to compute proof data for a commitment at mining time + [[nodiscard]] std::optional ComputeQuorumProofData( + const CBlockIndex* pMinedBlock, + Consensus::LLMQType llmqType, + const uint256& quorumHash, + const CBlock& block) const; }; // Database key prefix for chainlock index @@ -253,6 +291,16 @@ static constexpr size_t MAX_PROOF_CHAIN_LENGTH = 500; // This limits how far forward we search from a block's height to find coverage static constexpr int32_t MAX_CHAINLOCK_SEARCH_OFFSET = 100; +// Database key prefix for quorum proof data index +static const std::string DB_QUORUM_PROOF_DATA = "q_qpd"; + +// Database key for quorum proof index version (for migration tracking) +static const std::string DB_QUORUM_PROOF_INDEX_VERSION = "q_qpv"; + +// Current version of the quorum proof index +// Increment this when the index format changes to trigger re-migration +static constexpr int QUORUM_PROOF_INDEX_VERSION = 1; + } // namespace llmq #endif // BITCOIN_LLMQ_QUORUMPROOFS_H From c9c124f3513308b3d693b01024df3d6a5980a50a Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 20:57:28 -0600 Subject: [PATCH 11/12] refactor(llmq): break circular dependencies in quorumproofs Move QuorumMerkleProof and QuorumProofData structs to a new header file (quorumproofdata.h) to break circular dependencies between llmq/blockprocessor, llmq/quorumproofs, evo/cbtx, and llmq/quorumsman. Also fixes trailing whitespace in quorumproofs.cpp. Co-Authored-By: Claude Opus 4.5 --- src/Makefile.am | 1 + src/llmq/blockprocessor.cpp | 2 +- src/llmq/quorumproofdata.h | 71 +++++++++++++++++++++++++++++++++++++ src/llmq/quorumproofs.cpp | 4 +-- src/llmq/quorumproofs.h | 52 +-------------------------- 5 files changed, 76 insertions(+), 54 deletions(-) create mode 100644 src/llmq/quorumproofdata.h diff --git a/src/Makefile.am b/src/Makefile.am index 2e2056cd350d..df0b5be6e919 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -282,6 +282,7 @@ BITCOIN_CORE_H = \ llmq/ehf_signals.h \ llmq/options.h \ llmq/params.h \ + llmq/quorumproofdata.h \ llmq/quorumproofs.h \ llmq/quorums.h \ llmq/quorumsman.h \ diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 5938c5951588..2873422520e8 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -5,7 +5,7 @@ #include #include #include -#include +#include #include #include diff --git a/src/llmq/quorumproofdata.h b/src/llmq/quorumproofdata.h new file mode 100644 index 000000000000..02a333923a97 --- /dev/null +++ b/src/llmq/quorumproofdata.h @@ -0,0 +1,71 @@ +// Copyright (c) 2025 The Dash Core developers +// Distributed under the MIT/X11 software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_LLMQ_QUORUMPROOFDATA_H +#define BITCOIN_LLMQ_QUORUMPROOFDATA_H + +#include +#include +#include +#include + +#include +#include + +class UniValue; + +namespace llmq { + +// Maximum merkle path length (DoS protection) +// A path of 32 levels can support 2^32 leaves, which is more than sufficient +static constexpr size_t MAX_MERKLE_PATH_LENGTH = 32; + +/** + * Merkle proof for a quorum commitment within the merkleRootQuorums. + * Allows verification that a commitment is included in a block's cbtx. + */ +struct QuorumMerkleProof { + std::vector merklePath; // Sibling hashes from leaf to root + std::vector merklePathSide; // true = right sibling, false = left + + SERIALIZE_METHODS(QuorumMerkleProof, obj) { + READWRITE(obj.merklePath, DYNBITSET(obj.merklePathSide)); + } + + /** + * Verify the merkle proof for a given leaf hash against an expected root. + * @param leafHash The hash of the commitment (SerializeHash of CFinalCommitment) + * @param expectedRoot The merkleRootQuorums from the cbtx + * @return true if the proof is valid + */ + [[nodiscard]] bool Verify(const uint256& leafHash, const uint256& expectedRoot) const; + + [[nodiscard]] UniValue ToJson() const; +}; + +/** + * Pre-computed proof data for a quorum commitment. + * Stored in DB when a commitment is mined and retrieved for proof chain generation. + * This avoids expensive disk reads and merkle proof computations at query time. + */ +struct QuorumProofData { + QuorumMerkleProof quorumMerkleProof; // Proof within merkleRootQuorums + CTransactionRef coinbaseTx; // The coinbase transaction containing merkleRootQuorums + std::vector coinbaseMerklePath; // Proof that coinbaseTx is in block's merkle root + std::vector coinbaseMerklePathSide; // Side indicators for coinbase merkle proof + CBlockHeader header; // Block header where quorum was mined + + SERIALIZE_METHODS(QuorumProofData, obj) { + READWRITE(obj.quorumMerkleProof, obj.coinbaseTx, + obj.coinbaseMerklePath, DYNBITSET(obj.coinbaseMerklePathSide), + obj.header); + } +}; + +// Database key prefix for quorum proof data index +static const std::string DB_QUORUM_PROOF_DATA = "q_qpd"; + +} // namespace llmq + +#endif // BITCOIN_LLMQ_QUORUMPROOFDATA_H diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index 5e16ccd0a81c..f7e7733277e1 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -555,12 +555,12 @@ std::optional CQuorumProofManager::BuildProofChain( // For the first step, we need the full commitment object and we need to know where it was mined. // We use GetMinedCommitment which gives us both. auto [targetQc, targetMinedHash] = m_quorum_block_processor.GetMinedCommitment(targetQuorumType, targetQuorumHash); - + if (targetQc.IsNull()) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Target quorum not found\n", __func__); return std::nullopt; } - + CFinalCommitment currentCommitment = targetQc; uint256 currentMinedBlockHash = targetMinedHash; diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index bb4c93b9fdc5..fddc1baa6d1d 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -7,9 +7,8 @@ #include #include +#include #include -#include -#include #include #include @@ -60,29 +59,6 @@ struct ChainlockProofEntry { [[nodiscard]] UniValue ToJson() const; }; -/** - * Merkle proof for a quorum commitment within the merkleRootQuorums. - * Allows verification that a commitment is included in a block's cbtx. - */ -struct QuorumMerkleProof { - std::vector merklePath; // Sibling hashes from leaf to root - std::vector merklePathSide; // true = right sibling, false = left - - SERIALIZE_METHODS(QuorumMerkleProof, obj) { - READWRITE(obj.merklePath, DYNBITSET(obj.merklePathSide)); - } - - /** - * Verify the merkle proof for a given leaf hash against an expected root. - * @param leafHash The hash of the commitment (SerializeHash of CFinalCommitment) - * @param expectedRoot The merkleRootQuorums from the cbtx - * @return true if the proof is valid - */ - [[nodiscard]] bool Verify(const uint256& leafHash, const uint256& expectedRoot) const; - - [[nodiscard]] UniValue ToJson() const; -}; - /** * Complete proof for a single quorum commitment. * Links a commitment to a chainlocked block via merkle proofs. @@ -162,25 +138,6 @@ struct QuorumProofVerifyResult { // Maps (llmqType, quorumHash) -> SerializeHash(commitment) using CommitmentHashCache = std::map, uint256>; -/** - * Pre-computed proof data for a quorum commitment. - * Stored in DB when a commitment is mined and retrieved for proof chain generation. - * This avoids expensive disk reads and merkle proof computations at query time. - */ -struct QuorumProofData { - QuorumMerkleProof quorumMerkleProof; // Proof within merkleRootQuorums - CTransactionRef coinbaseTx; // The coinbase transaction containing merkleRootQuorums - std::vector coinbaseMerklePath; // Proof that coinbaseTx is in block's merkle root - std::vector coinbaseMerklePathSide; // Side indicators for coinbase merkle proof - CBlockHeader header; // Block header where quorum was mined - - SERIALIZE_METHODS(QuorumProofData, obj) { - READWRITE(obj.quorumMerkleProof, obj.coinbaseTx, - obj.coinbaseMerklePath, DYNBITSET(obj.coinbaseMerklePathSide), - obj.header); - } -}; - /** * Manager for chainlock indexing and quorum proof generation/verification. */ @@ -279,10 +236,6 @@ static const std::string DB_CHAINLOCK_INDEX_VERSION = "q_clv"; // Increment this when the index format changes to trigger re-migration static constexpr int CHAINLOCK_INDEX_VERSION = 2; -// Maximum merkle path length (DoS protection) -// A path of 32 levels can support 2^32 leaves, which is more than sufficient -static constexpr size_t MAX_MERKLE_PATH_LENGTH = 32; - // Maximum proof chain length (DoS protection) // Limits how many intermediate quorums can be proven in a single chain static constexpr size_t MAX_PROOF_CHAIN_LENGTH = 500; @@ -291,9 +244,6 @@ static constexpr size_t MAX_PROOF_CHAIN_LENGTH = 500; // This limits how far forward we search from a block's height to find coverage static constexpr int32_t MAX_CHAINLOCK_SEARCH_OFFSET = 100; -// Database key prefix for quorum proof data index -static const std::string DB_QUORUM_PROOF_DATA = "q_qpd"; - // Database key for quorum proof index version (for migration tracking) static const std::string DB_QUORUM_PROOF_INDEX_VERSION = "q_qpv"; From 935795ff262dcae4b5b96858d0f952c8ab953935 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 19 Jan 2026 09:40:57 -0600 Subject: [PATCH 12/12] fix(llmq): address PR #7107 review feedback - Change ComputeSigningCommitmentIndex to return std::optional to avoid silent fallback that could mis-attribute signers - Remove unnecessary fallback path in BuildProofChain (migration ensures all historical commitments are indexed) - Remove legacy header continuity check that incorrectly assumed consecutive blocks (headers are from commitment blocks spaced by DKG intervals) - Add LLMQ type validation in verifyquorumproofchain RPC - Use uint8_t for LLMQ type cast (matches enum class : uint8_t) - Reduce cs_main lock scope using WITH_LOCK - Fix /*optional=*/ syntax and RPC example placeholder - Change int32_t to int for chainlockedHeight (style consistency) - Update regression test for count mismatch validation - Fix functional test params (5,3) and remove unnecessary delay Co-Authored-By: Claude Opus 4.5 --- src/evo/specialtxman.cpp | 4 +- src/llmq/quorumproofs.cpp | 67 ++++--------------- src/rpc/quorums.cpp | 28 ++++---- src/test/quorum_proofs_regression_tests.cpp | 30 ++++----- test/functional/feature_quorum_proof_chain.py | 9 ++- 5 files changed, 47 insertions(+), 91 deletions(-) diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 0a68652bc45d..0c39c12fb366 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -668,7 +668,7 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(const CBlock& block, const CB // This prevents indexing chainlocks from blocks during a reorg if (!fJustCheck && opt_cbTx->bestCLSignature.IsValid() && m_chainman.ActiveChain().Contains(pindex)) { - int32_t chainlockedHeight = pindex->nHeight - static_cast(opt_cbTx->bestCLHeightDiff) - 1; + int chainlockedHeight = pindex->nHeight - static_cast(opt_cbTx->bestCLHeightDiff) - 1; const CBlockIndex* pChainlockedBlock = pindex->GetAncestor(chainlockedHeight); if (pChainlockedBlock) { m_quorum_proof_manager.IndexChainlock( @@ -741,7 +741,7 @@ bool CSpecialTxProcessor::UndoSpecialTxsInBlock(const CBlock& block, const CBloc // Remove chainlock index for this block's cbtx if (block.vtx.size() > 0 && block.vtx[0]->nType == TRANSACTION_COINBASE) { if (const auto opt_cbTx = GetTxPayload(*block.vtx[0]); opt_cbTx && opt_cbTx->bestCLSignature.IsValid()) { - int32_t chainlockedHeight = pindex->nHeight - static_cast(opt_cbTx->bestCLHeightDiff) - 1; + int chainlockedHeight = pindex->nHeight - static_cast(opt_cbTx->bestCLHeightDiff) - 1; m_quorum_proof_manager.RemoveChainlockIndex(chainlockedHeight); } } diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index f7e7733277e1..aa6acba6e279 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -48,9 +48,9 @@ struct CachedCommitmentInfo { * @param llmq_params The LLMQ parameters * @param commitments Cached commitment info (must be non-empty) * @param selectionHash The request ID for the chainlock (GenSigRequestId(height)) - * @return Index into commitments vector of the selected quorum + * @return Index into commitments vector of the selected quorum, or std::nullopt if not found */ -static size_t ComputeSigningCommitmentIndex( +static std::optional ComputeSigningCommitmentIndex( const Consensus::LLMQParams& llmq_params, const std::vector& commitments, const uint256& selectionHash) @@ -68,7 +68,7 @@ static size_t ComputeSigningCommitmentIndex( return i; } } - return 0; // Fallback to first if not found + return std::nullopt; // Quorum index not found in rotated quorums } else { // For non-rotated quorums, selection is based on hash score std::vector> scores; @@ -544,7 +544,6 @@ std::optional CQuorumProofManager::BuildProofChain( // the chainlock height that covers its mined block struct ProofStep { CFinalCommitment commitment; - const CBlockIndex* pMinedBlockIndex; // Block where commitment was actually mined int32_t chainlockHeight; std::optional chainlockEntry; }; @@ -656,7 +655,13 @@ std::optional CQuorumProofManager::BuildProofChain( for (int32_t h = pMinedBlock->nHeight; h <= maxSearchHeight; ++h) { // Compute which commitment would sign this height using cached data const uint256 requestId = chainlock::GenSigRequestId(h); - size_t signerIdx = ComputeSigningCommitmentIndex(llmq_params, cachedCommitments, requestId); + auto signerIdxOpt = ComputeSigningCommitmentIndex(llmq_params, cachedCommitments, requestId); + if (!signerIdxOpt.has_value()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not determine signing quorum for height %d\n", + __func__, h); + continue; + } + size_t signerIdx = signerIdxOpt.value(); const auto& signer = cachedCommitments[signerIdx]; bool isKnown = knownQuorumPubKeys.count(signer.publicKey); @@ -696,11 +701,10 @@ std::optional CQuorumProofManager::BuildProofChain( return std::nullopt; } - const CBlockIndex* pProofBlock = active_chain[bestBlockHeight]; LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Selected proof block %d (mined %d). KnownSigner=%d\n", __func__, bestBlockHeight, pMinedBlock->nHeight, foundKnownSigner); - proofSteps.push_back({currentCommitment, pProofBlock, bestChainlockHeight, bestChainlockEntry}); + proofSteps.push_back({currentCommitment, bestChainlockHeight, bestChainlockEntry}); if (foundKnownSigner) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Signing quorum's public key is in checkpoint - chain complete!\n", __func__); @@ -726,10 +730,6 @@ std::optional CQuorumProofManager::BuildProofChain( QuorumProofChain chain; std::set includedChainlockHeights; - // Cache commitment hashes across proof steps to avoid repeated DB reads - // Consecutive steps often share many of the same active commitments - CommitmentHashCache commitmentHashCache; - for (const auto& step : proofSteps) { // Add chainlock entry if not already included if (!includedChainlockHeights.count(step.chainlockHeight)) { @@ -783,41 +783,9 @@ std::optional CQuorumProofManager::BuildProofChain( chain.quorumProofs.push_back(commitmentProof); chain.headers.push_back(proofData->header); } else { - // Fallback: compute proof data on-the-fly (slow path - for backwards compatibility) - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No cached proof data for quorum %s, computing on-the-fly\n", + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No cached proof data for quorum %s\n", __func__, step.commitment.quorumHash.ToString()); - - const CBlockIndex* pMinedBlock = step.pMinedBlockIndex; - - // Read the block to get coinbase transaction - CBlock block; - if (!ReadBlockFromDisk(block, pMinedBlock, Params().GetConsensus())) { - return std::nullopt; - } - - auto merkleProof = BuildQuorumMerkleProof(pMinedBlock, step.commitment.llmqType, step.commitment.quorumHash, &block, &commitmentHashCache); - if (!merkleProof.has_value()) { - return std::nullopt; - } - - // Build coinbase merkle proof - std::vector txHashes; - for (const auto& tx : block.vtx) { - txHashes.push_back(tx->GetHash()); - } - - auto [cbPath, cbSide] = BuildMerkleProofPath(txHashes, 0); // Coinbase is at index 0 - - QuorumCommitmentProof commitmentProof; - commitmentProof.commitment = step.commitment; - commitmentProof.chainlockIndex = chainlockIndex; - commitmentProof.quorumMerkleProof = merkleProof.value(); - commitmentProof.coinbaseTx = block.vtx[0]; - commitmentProof.coinbaseMerklePath = std::move(cbPath); - commitmentProof.coinbaseMerklePathSide = std::move(cbSide); - - chain.quorumProofs.push_back(commitmentProof); - chain.headers.push_back(block.GetBlockHeader()); + return std::nullopt; } } @@ -848,15 +816,6 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( return result; } - // Verify header chain continuity - each header's prevBlockHash must match the previous header's hash - // This prevents an attacker from mixing headers from different blockchain forks - for (size_t i = 1; i < proof.headers.size(); ++i) { - if (proof.headers[i].hashPrevBlock != proof.headers[i - 1].GetHash()) { - result.error = strprintf("Header chain is not continuous - prevBlockHash mismatch at index %d", i); - return result; - } - } - // Phase 1: Build initial set of known chainlock quorum public keys from checkpoint // We use a set of public keys since that's what we actually verify signatures against std::set knownQuorumPubKeys; diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index e4afb1ba189e..6a812a58b4af 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -1283,15 +1283,11 @@ static RPCHelpMan getchainlockbyheight() } // Get the block hash at the chainlocked height - uint256 blockHash; - { - LOCK(cs_main); - const CBlockIndex* pindex = chainman.ActiveChain()[height]; - if (pindex == nullptr) { - throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Block not found at height"); - } - blockHash = pindex->GetBlockHash(); + const CBlockIndex* pindex = WITH_LOCK(::cs_main, return chainman.ActiveChain()[height]); + if (pindex == nullptr) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Block not found at height"); } + uint256 blockHash = pindex->GetBlockHash(); UniValue result(UniValue::VOBJ); result.pushKV("height", height); @@ -1319,7 +1315,7 @@ static llmq::QuorumCheckpoint ParseCheckpointFromRPC(const UniValue& checkpointO const UniValue& q = quorumsArr[i]; llmq::QuorumCheckpoint::QuorumEntry entry; entry.quorumHash = ParseHashV(q["quorum_hash"], "quorum_hash"); - entry.quorumType = static_cast(q["quorum_type"].getInt()); + entry.quorumType = static_cast(q["quorum_type"].getInt()); if (!entry.publicKey.SetHexStr(q["public_key"].get_str(), /*specificLegacyScheme=*/false)) { throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid public_key format"); } @@ -1433,11 +1429,13 @@ static RPCHelpMan verifyquorumproofchain() RPCResult::Type::OBJ, "", "", { {RPCResult::Type::BOOL, "valid", "Whether the proof is valid"}, - {RPCResult::Type::STR_HEX, "quorum_public_key", /* optional */ true, "Verified public key (if valid)"}, - {RPCResult::Type::STR, "error", /* optional */ true, "Error message (if invalid)"}, + {RPCResult::Type::STR_HEX, "quorum_public_key", /*optional=*/ true, "Verified public key (if valid)"}, + {RPCResult::Type::STR, "error", /*optional=*/ true, "Error message (if invalid)"}, }}, RPCExamples{ - HelpExampleCli("verifyquorumproofchain", "'{...}' \"proof_hex\" \"quorum_hash\" 104") + HelpExampleCli("verifyquorumproofchain", + "'{\"block_hash\":\"0000...\",\"height\":100,\"chainlock_quorums\":[{\"quorum_hash\":\"abcd...\",\"quorum_type\":104,\"public_key\":\"1234...\"}]}' " + "\"\" \"\" 104") }, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { @@ -1461,7 +1459,11 @@ static RPCHelpMan verifyquorumproofchain() } const uint256 expectedQuorumHash = ParseHashV(request.params[2], "quorum_hash"); - const Consensus::LLMQType expectedType = static_cast(request.params[3].getInt()); + const Consensus::LLMQType expectedType = static_cast(request.params[3].getInt()); + + if (!Params().GetLLMQ(expectedType).has_value()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid LLMQ type"); + } auto verifyResult = llmq_ctx.quorum_proof_manager->VerifyProofChain( checkpoint, proofChain, expectedType, expectedQuorumHash); diff --git a/src/test/quorum_proofs_regression_tests.cpp b/src/test/quorum_proofs_regression_tests.cpp index 49960f3645af..0fb36f444048 100644 --- a/src/test/quorum_proofs_regression_tests.cpp +++ b/src/test/quorum_proofs_regression_tests.cpp @@ -118,10 +118,9 @@ BOOST_AUTO_TEST_CASE(forged_chainlock_signature_rejected) "Expected error about signature verification, got: " + result.error); } -// Regression test: Discontinuous header chain should be REJECTED -// BUG: VerifyProofChain doesn't validate header chain continuity -// This test FAILS before the fix (error is NOT about headers), PASSES after -BOOST_AUTO_TEST_CASE(discontinuous_headers_rejected) +// Test: Headers/proofs count mismatch should be REJECTED +// VerifyProofChain validates that headers count matches quorum proofs count +BOOST_AUTO_TEST_CASE(headers_proofs_count_mismatch_rejected) { if (!m_node.llmq_ctx || !m_node.llmq_ctx->quorum_block_processor) { BOOST_TEST_MESSAGE("Skipping test: LLMQ context not available"); @@ -144,9 +143,10 @@ BOOST_AUTO_TEST_CASE(discontinuous_headers_rejected) checkpointQuorum.publicKey = sk.GetPublicKey(); checkpoint.chainlockQuorums.push_back(checkpointQuorum); - // Create proof chain with DISCONTINUOUS headers + // Create proof chain with mismatched headers/proofs count llmq::QuorumProofChain chain; + // Add 2 headers CBlockHeader header1; header1.nVersion = 1; header1.hashPrevBlock = uint256::ZERO; @@ -157,8 +157,7 @@ BOOST_AUTO_TEST_CASE(discontinuous_headers_rejected) CBlockHeader header2; header2.nVersion = 1; - // BUG TRIGGER: prevBlockHash does NOT match header1.GetHash() - header2.hashPrevBlock = uint256::TWO; // Should be header1.GetHash() + header2.hashPrevBlock = header1.GetHash(); header2.hashMerkleRoot = uint256::TWO; header2.nTime = 1234567891; header2.nBits = 0x1d00ffff; @@ -174,7 +173,7 @@ BOOST_AUTO_TEST_CASE(discontinuous_headers_rejected) clEntry.signature = sk.Sign(clEntry.blockHash, false); chain.chainlocks.push_back(clEntry); - // Add quorum proof + // Add only 1 quorum proof (mismatch with 2 headers) llmq::QuorumCommitmentProof qProof; qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST; qProof.commitment.quorumHash = uint256::TWO; @@ -192,15 +191,12 @@ BOOST_AUTO_TEST_CASE(discontinuous_headers_rejected) BOOST_CHECK(!result.valid); - // REGRESSION CHECK: Error should mention "header" or "continuous" or "chain" - // BEFORE FIX: This FAILS because error is about something else - // AFTER FIX: This PASSES because error is about header continuity - bool errorMentionsHeaders = result.error.find("header") != std::string::npos || - result.error.find("Header") != std::string::npos || - result.error.find("continuous") != std::string::npos || - result.error.find("chain") != std::string::npos; - BOOST_CHECK_MESSAGE(errorMentionsHeaders, - "Expected error about header chain continuity, got: " + result.error); + // Verify error mentions count mismatch + bool errorMentionsCount = result.error.find("count") != std::string::npos || + result.error.find("Count") != std::string::npos || + result.error.find("match") != std::string::npos; + BOOST_CHECK_MESSAGE(errorMentionsCount, + "Expected error about headers/proofs count mismatch, got: " + result.error); } BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 09cc0ff2b4a9..75f6883e60a3 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -15,8 +15,7 @@ class QuorumProofChainTest(DashTestFramework): def set_test_params(self): - self.set_dash_test_params(5, 4) - self.delay_v20_and_mn_rr(height=200) + self.set_dash_test_params(5, 3) def run_test(self): # Connect all nodes to node1 so that we always have the whole network connected @@ -24,7 +23,7 @@ def run_test(self): for i in range(2, len(self.nodes)): self.connect_nodes(i, 1) - self.activate_v20(expected_activation_height=200) + self.activate_v20() self.log.info("Activated v20 at height:" + str(self.nodes[0].getblockcount())) # Enable quorum DKG @@ -53,7 +52,7 @@ def test_chainlock_index(self): tip_height = self.nodes[0].getblockcount() # Find a chainlocked height - for h in range(tip_height, 200, -1): + for h in range(tip_height, 0, -1): try: cl_info = self.nodes[0].getchainlockbyheight(h) self.log.info(f"Found chainlock at height {h}") @@ -85,7 +84,7 @@ def test_getchainlockbyheight(self): # Try to find a valid chainlocked height found = False - for h in range(tip_height, 200, -1): + for h in range(tip_height, 0, -1): try: result = self.nodes[0].getchainlockbyheight(h) assert_equal(result['height'], h)