From 11897c9c2dadef588411e27c0672a9c12c2d2c68 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 17 Jan 2026 02:32:22 -0600 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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) From a2d5aad7505500cb62acb1602a546ca205c24a59 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 16:24:05 -0600 Subject: [PATCH 13/27] test: fix build_checkpoint() to use LLMQ type 100 and update test setup - Change llmq_type from 104 to 100 (LLMQ_TEST for chainlocks) - Use quorum("list") without type argument and get "llmq_test" key - Add optional height parameter for checkpoint at specific height - Add set_dash_llmq_test_params(3, 2) for proper quorum configuration - Switch from mine_cycle_quorum() to mine_quorum() for type 100 - Remove unnecessary activate_v20() call Co-Authored-By: Claude Opus 4.5 --- test/functional/feature_quorum_proof_chain.py | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 75f6883e60a3..9c41b11f832b 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -16,6 +16,7 @@ class QuorumProofChainTest(DashTestFramework): def set_test_params(self): self.set_dash_test_params(5, 3) + self.set_dash_llmq_test_params(3, 2) def run_test(self): # Connect all nodes to node1 so that we always have the whole network connected @@ -23,16 +24,13 @@ def run_test(self): for i in range(2, len(self.nodes)): self.connect_nodes(i, 1) - self.activate_v20() - 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.log.info("Mining quorum...") + self.mine_quorum() self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) # Mine additional blocks to ensure chainlocks are indexed @@ -111,32 +109,31 @@ def test_getchainlockbyheight_errors(self): 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 = [] + def build_checkpoint(self, height=None): + """Build checkpoint from current or specified chain state.""" + llmq_type = 100 # LLMQ_TEST for chainlocks + + if height is None: + block_hash = self.nodes[0].getbestblockhash() + height = self.nodes[0].getblockcount() + else: + block_hash = self.nodes[0].getblockhash(height) + + quorum_list = self.nodes[0].quorum("list") + cl_quorums = quorum_list.get("llmq_test", []) 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 + info = self.nodes[0].quorum("info", llmq_type, qhash) + quorum_entries.append({ + 'quorum_hash': qhash, + 'quorum_type': llmq_type, + 'public_key': info['quorumPublicKey'] + }) return { - 'block_hash': self.nodes[0].getbestblockhash(), - 'height': self.nodes[0].getblockcount(), + 'block_hash': block_hash, + 'height': height, 'chainlock_quorums': quorum_entries } From d1cf5759ff978b5f5544bc32aea314f18ae8835a Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 16:25:40 -0600 Subject: [PATCH 14/27] test: add tamper_proof_hex() helper for proof chain tests Add helper method to tamper with serialized proof hex at specified byte offsets. This will be used to test that verification correctly rejects corrupted proofs. Co-Authored-By: Claude Opus 4.5 --- test/functional/feature_quorum_proof_chain.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 9c41b11f832b..01210539a650 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -137,6 +137,12 @@ def build_checkpoint(self, height=None): 'chainlock_quorums': quorum_entries } + def tamper_proof_hex(self, proof_hex, offset, new_byte): + """Tamper with proof_hex at specified byte offset.""" + proof_bytes = bytearray.fromhex(proof_hex) + proof_bytes[offset] = new_byte + return proof_bytes.hex() + if __name__ == '__main__': QuorumProofChainTest().main() From 4b0d4c10b28deec36fb3dfe1363300f40fffa285 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 16:50:41 -0600 Subject: [PATCH 15/27] fix(llmq): remove incorrect ActiveChain check in chainlock indexing The chainlock indexing code incorrectly checked if the block was in the active chain before indexing. This check always failed because ProcessSpecialTxsInBlock is called during ConnectBlock, but the chain tip is only updated AFTER ConnectBlock returns (via m_chain.SetTip()). This fix removes the ActiveChain().Contains() check since it's not needed - the fJustCheck flag already prevents indexing during validation only (as opposed to actual block connection). Co-Authored-By: Claude Opus 4.5 --- src/evo/specialtxman.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 0c39c12fb366..d91467198e87 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -664,10 +664,10 @@ bool CSpecialTxProcessor::ProcessSpecialTxsInBlock(const CBlock& block, const CB } // 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)) { + // Only index if not just checking + // Note: We can't check ActiveChain().Contains(pindex) here because the chain tip + // hasn't been updated yet during ConnectBlock - the tip is updated AFTER this function returns + if (!fJustCheck && opt_cbTx->bestCLSignature.IsValid()) { int chainlockedHeight = pindex->nHeight - static_cast(opt_cbTx->bestCLHeightDiff) - 1; const CBlockIndex* pChainlockedBlock = pindex->GetAncestor(chainlockedHeight); if (pChainlockedBlock) { From ec75ae26ec5d90c76eb0177c263bc6469a483eb3 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 16:51:35 -0600 Subject: [PATCH 16/27] test: add test_getquorumproofchain_single_step() and fix test setup - Add test_getquorumproofchain_single_step() test method - Fix V20 activation (required for chainlock signatures in cbtx) - Add debug logging for cbtx version and chainlock signature - Fix assertion key names (use camelCase 'quorumProofs' to match RPC) - Add explicit block generation after chainlocks for proper indexing Co-Authored-By: Claude Opus 4.5 --- test/functional/feature_quorum_proof_chain.py | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 01210539a650..5dd38920b10c 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -17,6 +17,8 @@ class QuorumProofChainTest(DashTestFramework): def set_test_params(self): self.set_dash_test_params(5, 3) self.set_dash_llmq_test_params(3, 2) + # Delay V20 activation to allow setup, then activate it for chainlock indexing + 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 @@ -24,24 +26,58 @@ def run_test(self): for i in range(2, len(self.nodes)): self.connect_nodes(i, 1) + # Activate V20 - required for chainlock signatures in coinbase transactions + 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...") + self.log.info("Mining first quorum...") + self.mine_quorum() + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + + # Mine a block AFTER the chainlock is received - this embeds the CL signature in cbtx + # The miner includes the best known chainlock in the coinbase transaction + self.log.info("Mining block to embed first chainlock signature...") + self.generate(self.nodes[0], 1, sync_fun=self.sync_blocks) + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + + # Mine a second quorum for additional chainlock coverage + self.log.info("Mining second quorum...") self.mine_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) + # Mine another block to embed the second chainlock + self.log.info("Mining block to embed second chainlock signature...") + self.generate(self.nodes[0], 1, sync_fun=self.sync_blocks) self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + # Mine additional blocks to ensure we have multiple chainlocks indexed + self.log.info("Mining additional blocks for chainlock indexing...") + for _ in range(5): + self.generate(self.nodes[0], 1, sync_fun=self.sync_blocks) + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + + # Debug: Check coinbase transaction for chainlock signature + self.log.info("Checking coinbase transaction for chainlock signature...") + block_hash = self.nodes[0].getbestblockhash() + block = self.nodes[0].getblock(block_hash, 2) + cbtx = block["cbTx"] + self.log.info(f"CbTx version: {cbtx['version']}") + if int(cbtx["version"]) > 2: + self.log.info(f"CbTx has bestCLHeightDiff: {cbtx.get('bestCLHeightDiff', 'N/A')}") + self.log.info(f"CbTx has bestCLSignature: {cbtx.get('bestCLSignature', 'N/A')[:32] + '...' if cbtx.get('bestCLSignature') else 'N/A'}") + else: + self.log.info("CbTx version is not v3 (CLSIG_AND_BALANCE) - chainlock signatures not supported") + # Run tests self.test_chainlock_index() self.test_getchainlockbyheight() self.test_getchainlockbyheight_errors() + self.test_getquorumproofchain_single_step() def test_chainlock_index(self): """Verify chainlocks are indexed from cbtx on block connect.""" @@ -143,6 +179,30 @@ def tamper_proof_hex(self, proof_hex, offset, new_byte): proof_bytes[offset] = new_byte return proof_bytes.hex() + def test_getquorumproofchain_single_step(self): + """Test single-step proof chain generation.""" + self.log.info("Testing single-step proof chain generation...") + + llmq_type = 100 + checkpoint = self.build_checkpoint() + + # Get a quorum to use as target (one from the checkpoint) + target_hash = checkpoint['chainlock_quorums'][0]['quorum_hash'] + + result = self.nodes[0].getquorumproofchain(checkpoint, target_hash, llmq_type) + + # Verify structure (RPC uses camelCase) + assert 'headers' in result + assert 'chainlocks' in result + assert 'quorumProofs' in result # camelCase + assert 'proof_hex' in result + + assert len(result['headers']) == len(result['quorumProofs']) + assert len(result['chainlocks']) >= 1 + assert len(result['proof_hex']) > 0 + + self.log.info("Single-step proof chain generation successful") + if __name__ == '__main__': QuorumProofChainTest().main() From cf87986f9b19b5e160679c2ba4492ae60a994cde Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 17:04:28 -0600 Subject: [PATCH 17/27] test: add skeleton test_verifyquorumproofchain_success() (blocked by C++ bug) Add the test method for proof chain verification, but skip it due to a bug discovered in BuildProofChain (quorumproofs.cpp:655-696). The bug: BuildProofChain incorrectly identifies which quorum signed a chainlock when quorum rotation has occurred. It uses cached commitments from the mined block height, but the actual chainlock at a later height may have been signed by different active commitments. See activity.md for full bug details. Co-Authored-By: Claude Opus 4.5 --- activity.md | 65 +++++++++++++++++++ test/functional/feature_quorum_proof_chain.py | 14 ++++ 2 files changed, 79 insertions(+) create mode 100644 activity.md diff --git a/activity.md b/activity.md new file mode 100644 index 000000000000..6397ef9246df --- /dev/null +++ b/activity.md @@ -0,0 +1,65 @@ +# Activity Log - Quorum Proof Chain Tests + +## Status Legend +- [ ] Not started +- [x] Completed +- [!] Failed/Blocked + +## Tasks + +### Phase 1: Fix Existing Infrastructure +- [x] Fix `build_checkpoint()` to use LLMQ type 100 instead of 104 + +### Phase 2: Add Helper Methods +- [x] Add `tamper_proof_hex()` helper method + +### Phase 3: Single-Step Proof Chain Tests +- [x] Implement `test_getquorumproofchain_single_step()` (required bug fix in specialtxman.cpp) +- [!] Implement `test_verifyquorumproofchain_success()` - BLOCKED: Bug found in C++ BuildProofChain + +### Phase 4: Verification Failure Tests +- [ ] Implement `test_verifyquorumproofchain_tampered()` +- [ ] Implement `test_verifyquorumproofchain_wrong_target()` +- [ ] Implement `test_verifyquorumproofchain_wrong_checkpoint()` + +### Phase 5: Error Handling Tests +- [ ] Implement `test_getquorumproofchain_errors()` + +### Phase 6: Multi-Step Proof Chain Tests +- [ ] Implement `test_getquorumproofchain_multi_step()` + +### Phase 7: Integration +- [ ] Update `run_test()` to call all new test methods in correct order +- [ ] Final verification - run full test suite + +## Completion Log + +| Date | Task | Status | Commit | +|------|------|--------|--------| +| 2026-01-20 | Fix build_checkpoint() | Completed | a2d5aad7505 | +| 2026-01-20 | Add tamper_proof_hex() helper | Completed | d1cf5759ff9 | +| 2026-01-20 | Fix chainlock indexing bug | Completed | 4b0d4c10b28 | +| 2026-01-20 | test_getquorumproofchain_single_step | Completed | ec75ae26ec5 | + +## Resolved: Chainlock Indexing Bug + +**Root Cause Found:** The `ActiveChain().Contains(pindex)` check in `specialtxman.cpp:670` always returned false during block connection because the chain tip is updated AFTER `ProcessSpecialTxsInBlock` returns. + +**Fix:** Removed the incorrect `ActiveChain().Contains(pindex)` check since `!fJustCheck` is sufficient to distinguish real block connection from validation-only. + +## Blocked: BuildProofChain Signer Detection Bug + +**Issue Found:** The `BuildProofChain` function in `quorumproofs.cpp` incorrectly identifies which quorum signed a chainlock. + +**Details:** +- `BuildProofChain` searches for a chainlock at heights from `pMinedBlock->nHeight` to `maxSearchHeight` +- For each height, it computes which commitment WOULD sign using `cachedCommitments` +- The `cachedCommitments` are fetched from the chain at `pMinedBlock->nHeight - SIGN_HEIGHT_OFFSET` +- But the actual chainlock at a higher height might have been signed by DIFFERENT active commitments +- The function claims `KnownSigner=1` but verification fails because the actual signer differs + +**Root Cause:** The code assumes commitments active at the mined block height are the same as those that actually signed the chainlock at a later height. With quorum rotation, this assumption is incorrect. + +**Location:** `src/llmq/quorumproofs.cpp:655-696` (the search loop in `BuildProofChain`) + +**Impact:** Proof chain verification fails when the target quorum is mined multiple DKG cycles after the checkpoint quorums. The proof generator thinks it found a valid signer but the actual chainlock was signed by a rotated-out quorum. diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 5dd38920b10c..a39665337fb1 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -78,6 +78,7 @@ def run_test(self): self.test_getchainlockbyheight() self.test_getchainlockbyheight_errors() self.test_getquorumproofchain_single_step() + self.test_verifyquorumproofchain_success() def test_chainlock_index(self): """Verify chainlocks are indexed from cbtx on block connect.""" @@ -203,6 +204,19 @@ def test_getquorumproofchain_single_step(self): self.log.info("Single-step proof chain generation successful") + def test_verifyquorumproofchain_success(self): + """Test successful proof chain verification. + + NOTE: This test is currently SKIPPED due to a bug in BuildProofChain that + incorrectly identifies which quorum signed a chainlock when quorum rotation + has occurred. The bug is in src/llmq/quorumproofs.cpp:655-696. + See activity.md for details. + """ + self.log.info("Testing proof chain verification...") + self.log.info("SKIPPED: BuildProofChain has a bug with signer detection after quorum rotation") + self.log.info("See activity.md for bug details") + return # Skip until C++ bug is fixed + if __name__ == '__main__': QuorumProofChainTest().main() From 38e969a310706e1c76ee262a5b7a83e4ea800fe7 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 17:24:15 -0600 Subject: [PATCH 18/27] fix(llmq): fix BuildProofChain signer detection and VerifyProofChain signature verification Two bugs prevented proof chain verification from working: 1. BuildProofChain cached chainlock signing commitments at a fixed reference height (pMinedBlock->nHeight - SIGN_HEIGHT_OFFSET) and used them for all heights in the search window. Due to quorum rotation, the active commitments at a later chainlock height can differ. Fix: use DetermineChainlockSigningCommitment() for each height, which correctly looks at commitments at (h - SIGN_HEIGHT_OFFSET). 2. VerifyProofChain used blockHash directly as the verification message, but chainlocks are signed using SignHash(llmqType, quorumHash, requestId, blockHash). Fix: add signingQuorumHash and signingQuorumType to ChainlockProofEntry (matching the DIP spec), and use proper SignHash construction for verification. Co-Authored-By: Claude Opus 4.5 --- activity.md | 30 ++- src/llmq/quorumproofs.cpp | 210 ++++++------------ src/llmq/quorumproofs.h | 4 +- test/functional/feature_quorum_proof_chain.py | 32 ++- 4 files changed, 116 insertions(+), 160 deletions(-) diff --git a/activity.md b/activity.md index 6397ef9246df..98112e2978aa 100644 --- a/activity.md +++ b/activity.md @@ -15,7 +15,7 @@ ### Phase 3: Single-Step Proof Chain Tests - [x] Implement `test_getquorumproofchain_single_step()` (required bug fix in specialtxman.cpp) -- [!] Implement `test_verifyquorumproofchain_success()` - BLOCKED: Bug found in C++ BuildProofChain +- [x] Implement `test_verifyquorumproofchain_success()` - Fixed bugs in BuildProofChain and VerifyProofChain ### Phase 4: Verification Failure Tests - [ ] Implement `test_verifyquorumproofchain_tampered()` @@ -40,6 +40,8 @@ | 2026-01-20 | Add tamper_proof_hex() helper | Completed | d1cf5759ff9 | | 2026-01-20 | Fix chainlock indexing bug | Completed | 4b0d4c10b28 | | 2026-01-20 | test_getquorumproofchain_single_step | Completed | ec75ae26ec5 | +| 2026-01-20 | Fix BuildProofChain & VerifyProofChain bugs | Completed | pending | +| 2026-01-20 | test_verifyquorumproofchain_success | Completed | pending | ## Resolved: Chainlock Indexing Bug @@ -47,19 +49,23 @@ **Fix:** Removed the incorrect `ActiveChain().Contains(pindex)` check since `!fJustCheck` is sufficient to distinguish real block connection from validation-only. -## Blocked: BuildProofChain Signer Detection Bug +## Resolved: BuildProofChain and VerifyProofChain Bugs -**Issue Found:** The `BuildProofChain` function in `quorumproofs.cpp` incorrectly identifies which quorum signed a chainlock. +### Bug 1: BuildProofChain Signer Detection -**Details:** -- `BuildProofChain` searches for a chainlock at heights from `pMinedBlock->nHeight` to `maxSearchHeight` -- For each height, it computes which commitment WOULD sign using `cachedCommitments` -- The `cachedCommitments` are fetched from the chain at `pMinedBlock->nHeight - SIGN_HEIGHT_OFFSET` -- But the actual chainlock at a higher height might have been signed by DIFFERENT active commitments -- The function claims `KnownSigner=1` but verification fails because the actual signer differs +**Issue:** The `BuildProofChain` function incorrectly identified which quorum signed a chainlock due to caching commitments at a fixed reference height. -**Root Cause:** The code assumes commitments active at the mined block height are the same as those that actually signed the chainlock at a later height. With quorum rotation, this assumption is incorrect. +**Root Cause:** The code cached commitments at `pMinedBlock->nHeight - SIGN_HEIGHT_OFFSET` and used them for ALL heights in the search window. With quorum rotation, the active commitments at a later chainlock height can differ. -**Location:** `src/llmq/quorumproofs.cpp:655-696` (the search loop in `BuildProofChain`) +**Fix:** Use `DetermineChainlockSigningCommitment(h, ...)` for each height `h` in the search loop, which correctly computes the signing commitment by looking at commitments at `h - SIGN_HEIGHT_OFFSET`. -**Impact:** Proof chain verification fails when the target quorum is mined multiple DKG cycles after the checkpoint quorums. The proof generator thinks it found a valid signer but the actual chainlock was signed by a rotated-out quorum. +### Bug 2: VerifyProofChain Signature Verification + +**Issue:** Chainlock signature verification used `blockHash` directly as the message, but chainlocks are signed using `SignHash(llmqType, quorumHash, requestId, blockHash)`. + +**Root Cause:** The verifier tried to verify signatures against all known public keys using only the block hash, ignoring the SignHash construction that includes the quorum hash and request ID. + +**Fix:** +1. Added `signingQuorumHash` and `signingQuorumType` fields to `ChainlockProofEntry` +2. Updated verification to build proper `SignHash` using `chainlock::GenSigRequestId(height)` +3. Verify against the specific signing quorum's public key instead of all known keys diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index aa6acba6e279..fa504814ab36 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -29,62 +29,6 @@ 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, or std::nullopt if not found - */ -static std::optional 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 std::nullopt; // Quorum index not found in rotated quorums - } 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 // @@ -95,6 +39,8 @@ UniValue ChainlockProofEntry::ToJson() const obj.pushKV("height", nHeight); obj.pushKV("blockhash", blockHash.ToString()); obj.pushKV("signature", signature.ToString()); + obj.pushKV("signingQuorumHash", signingQuorumHash.ToString()); + obj.pushKV("signingQuorumType", static_cast(signingQuorumType)); return obj; } @@ -546,6 +492,8 @@ std::optional CQuorumProofManager::BuildProofChain( CFinalCommitment commitment; int32_t chainlockHeight; std::optional chainlockEntry; + uint256 signingQuorumHash; // Hash of quorum that signed the chainlock + Consensus::LLMQType signingQuorumType{Consensus::LLMQType::LLMQ_NONE}; // Type of signing quorum }; std::vector proofSteps; std::set visitedQuorums; // Cycle detection @@ -600,86 +548,53 @@ std::optional CQuorumProofManager::BuildProofChain( int32_t bestBlockHeight = -1; int32_t bestChainlockHeight = -1; - size_t bestSignerIndex = 0; + std::optional bestSignerCommitment; std::optional bestChainlockEntry; // Metrics for selection bool foundKnownSigner = false; int32_t oldestSignerHeight = std::numeric_limits::max(); - // 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; - } + // Search for the best chainlock to prove this quorum + // IMPORTANT: For each height h, the signing commitment is determined by the active + // quorums at height (h - SIGN_HEIGHT_OFFSET), which can differ across the search window + // due to quorum rotation. We must use DetermineChainlockSigningCommitment for correctness. + for (int32_t h = pMinedBlock->nHeight; h <= maxSearchHeight; ++h) { + // Get chainlock for this height first (quick check before expensive commitment lookup) + auto clEntry = GetChainlockByHeight(h); + if (!clEntry.has_value()) continue; - // 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)); + // Determine which commitment signed this chainlock at height h + // This correctly handles quorum rotation by looking at commitments at (h - SIGN_HEIGHT_OFFSET) + auto signerCommitment = DetermineChainlockSigningCommitment(h, active_chain, qman); + if (!signerCommitment.has_value()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not determine signing commitment for chainlock at height %d\n", + __func__, h); + continue; } - if (cachedCommitments.empty()) { - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not resolve mined blocks for commitments\n", __func__); - return std::nullopt; - } - } + bool isKnown = knownQuorumPubKeys.count(signerCommitment->quorumPublicKey); - // Search window using cached data - no DB reads in the inner loop - 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); - 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); + // Get the signer's mined block height for prioritization + uint256 signerMinedBlockHash = m_quorum_block_processor.GetMinedCommitmentBlockHash( + signerCommitment->llmqType, signerCommitment->quorumHash); + const CBlockIndex* pSignerMinedBlock = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(signerMinedBlockHash)); + if (!pSignerMinedBlock) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not find mined block for signer %s\n", + __func__, signerCommitment->quorumHash.ToString()); continue; } - size_t signerIdx = signerIdxOpt.value(); - const auto& signer = cachedCommitments[signerIdx]; - - bool isKnown = knownQuorumPubKeys.count(signer.publicKey); - int32_t signerHeight = signer.pMinedBlock->nHeight; + int32_t signerHeight = pSignerMinedBlock->nHeight; // Skip if this signer is not interesting bool isInteresting = isKnown || bestBlockHeight == -1 || signerHeight < oldestSignerHeight; if (!isInteresting) continue; - // 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; - bestSignerIndex = signerIdx; + bestSignerCommitment = signerCommitment; bestChainlockEntry = clEntry; foundKnownSigner = true; break; @@ -688,7 +603,7 @@ std::optional CQuorumProofManager::BuildProofChain( if (bestBlockHeight == -1 || signerHeight < oldestSignerHeight) { bestBlockHeight = h; bestChainlockHeight = h; - bestSignerIndex = signerIdx; + bestSignerCommitment = signerCommitment; bestChainlockEntry = clEntry; oldestSignerHeight = signerHeight; } @@ -704,22 +619,27 @@ std::optional CQuorumProofManager::BuildProofChain( LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Selected proof block %d (mined %d). KnownSigner=%d\n", __func__, bestBlockHeight, pMinedBlock->nHeight, foundKnownSigner); - proofSteps.push_back({currentCommitment, bestChainlockHeight, bestChainlockEntry}); + // Store proof step with signing quorum info + assert(bestSignerCommitment.has_value()); + proofSteps.push_back({currentCommitment, bestChainlockHeight, bestChainlockEntry, + bestSignerCommitment->quorumHash, bestSignerCommitment->llmqType}); 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 - 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()); + // Need to prove the signer - we already have the commitment from DetermineChainlockSigningCommitment + // Just need to get the mined block hash for the next iteration + assert(bestSignerCommitment.has_value()); + uint256 signerMinedHash = m_quorum_block_processor.GetMinedCommitmentBlockHash( + bestSignerCommitment->llmqType, bestSignerCommitment->quorumHash); + if (signerMinedHash.IsNull()) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not find mined block for signer %s\n", + __func__, bestSignerCommitment->quorumHash.ToString()); return std::nullopt; } - currentCommitment = std::move(signerCommitment); + currentCommitment = std::move(*bestSignerCommitment); currentMinedBlockHash = signerMinedHash; } @@ -754,6 +674,8 @@ std::optional CQuorumProofManager::BuildProofChain( clProof.nHeight = step.chainlockHeight; clProof.blockHash = pClBlock->GetBlockHash(); clProof.signature = clEntry->signature; + clProof.signingQuorumHash = step.signingQuorumHash; + clProof.signingQuorumType = step.signingQuorumType; chain.chainlocks.push_back(clProof); includedChainlockHeights.insert(step.chainlockHeight); } @@ -816,10 +738,12 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( 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; + // Phase 1: Build maps of known chainlock quorums from checkpoint + // We need to look up quorums by hash (for signature verification) and track all known public keys + std::map knownQuorumsByHash; // quorumHash -> publicKey + std::set knownQuorumPubKeys; // All known public keys (for quick membership check) for (const auto& q : checkpoint.chainlockQuorums) { + knownQuorumsByHash[q.quorumHash] = q.publicKey; knownQuorumPubKeys.insert(q.publicKey); } @@ -846,18 +770,29 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( 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); - }; + // Look up the signing quorum's public key + auto it = knownQuorumsByHash.find(chainlock.signingQuorumHash); + if (it == knownQuorumsByHash.end()) { + result.error = strprintf("Chainlock at height %d signed by unknown quorum %s", + chainlock.nHeight, chainlock.signingQuorumHash.ToString()); + return result; + } + const CBLSPublicKey& signerPubKey = it->second; - const bool signatureVerified = std::any_of(knownQuorumPubKeys.begin(), knownQuorumPubKeys.end(), verifyAgainstKey); + // Build the SignHash for chainlock verification + // Chainlocks use: SignHash(llmqType, quorumHash, requestId, msgHash) + // where requestId = GenSigRequestId(height) and msgHash = blockHash + const uint256 requestId = chainlock::GenSigRequestId(chainlock.nHeight); + SignHash signHash{chainlock.signingQuorumType, chainlock.signingQuorumHash, requestId, chainlock.blockHash}; + + // Verify signature against the SignHash + // Try both BLS schemes (non-legacy post-v19, legacy pre-v19) + const bool signatureVerified = + chainlock.signature.VerifyInsecure(signerPubKey, signHash.Get(), /*specificLegacyScheme=*/false) || + chainlock.signature.VerifyInsecure(signerPubKey, signHash.Get(), /*specificLegacyScheme=*/true); if (!signatureVerified) { - result.error = strprintf("Chainlock signature verification failed at height %d - signature does not match any known quorum key", chainlock.nHeight); + result.error = strprintf("Chainlock signature verification failed at height %d", chainlock.nHeight); return result; } @@ -896,7 +831,8 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( return result; } - // This quorum is now proven! Add its public key to known keys for subsequent proofs + // This quorum is now proven! Add to known quorums for subsequent proofs + knownQuorumsByHash[qProof.commitment.quorumHash] = qProof.commitment.quorumPublicKey; knownQuorumPubKeys.insert(qProof.commitment.quorumPublicKey); // Check if this is the target quorum diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index fddc1baa6d1d..065adec58f57 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -51,9 +51,11 @@ struct ChainlockProofEntry { int32_t nHeight{0}; uint256 blockHash; CBLSSignature signature; + uint256 signingQuorumHash; // Hash of the quorum that signed this chainlock + Consensus::LLMQType signingQuorumType{Consensus::LLMQType::LLMQ_NONE}; // LLMQ type of the signing quorum SERIALIZE_METHODS(ChainlockProofEntry, obj) { - READWRITE(obj.nHeight, obj.blockHash, obj.signature); + READWRITE(obj.nHeight, obj.blockHash, obj.signature, obj.signingQuorumHash, obj.signingQuorumType); } [[nodiscard]] UniValue ToJson() const; diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index a39665337fb1..a380fc0e9894 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -205,17 +205,29 @@ def test_getquorumproofchain_single_step(self): self.log.info("Single-step proof chain generation successful") def test_verifyquorumproofchain_success(self): - """Test successful proof chain verification. - - NOTE: This test is currently SKIPPED due to a bug in BuildProofChain that - incorrectly identifies which quorum signed a chainlock when quorum rotation - has occurred. The bug is in src/llmq/quorumproofs.cpp:655-696. - See activity.md for details. - """ + """Test successful proof chain verification.""" self.log.info("Testing proof chain verification...") - self.log.info("SKIPPED: BuildProofChain has a bug with signer detection after quorum rotation") - self.log.info("See activity.md for bug details") - return # Skip until C++ bug is fixed + + llmq_type = 100 + checkpoint = self.build_checkpoint() + target_hash = checkpoint['chainlock_quorums'][0]['quorum_hash'] + + # Generate proof + proof_result = self.nodes[0].getquorumproofchain(checkpoint, target_hash, llmq_type) + proof_hex = proof_result['proof_hex'] + + # Verify proof + verify_result = self.nodes[0].verifyquorumproofchain( + checkpoint, proof_hex, target_hash, llmq_type) + + assert_equal(verify_result['valid'], True) + assert 'quorumPublicKey' in verify_result + + # Verify the public key matches + expected_pubkey = checkpoint['chainlock_quorums'][0]['public_key'] + assert_equal(verify_result['quorumPublicKey'], expected_pubkey) + + self.log.info("Proof chain verification successful") if __name__ == '__main__': From 3732a355c1a9c729f17b84ebe1915c7a7186e587 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 17:27:51 -0600 Subject: [PATCH 19/27] test: add test_verifyquorumproofchain_tampered() Test that tampered proof chains are correctly rejected by verifyquorumproofchain. Uses tamper_proof_hex() helper to modify a byte in the middle of a valid proof, then verifies the verification returns valid=false with an error message. Co-Authored-By: Claude Opus 4.5 --- test/functional/feature_quorum_proof_chain.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index a380fc0e9894..0372c0eea5ec 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -79,6 +79,7 @@ def run_test(self): self.test_getchainlockbyheight_errors() self.test_getquorumproofchain_single_step() self.test_verifyquorumproofchain_success() + self.test_verifyquorumproofchain_tampered() def test_chainlock_index(self): """Verify chainlocks are indexed from cbtx on block connect.""" @@ -229,6 +230,30 @@ def test_verifyquorumproofchain_success(self): self.log.info("Proof chain verification successful") + def test_verifyquorumproofchain_tampered(self): + """Test that tampered proofs are rejected.""" + self.log.info("Testing tampered proof detection...") + + llmq_type = 100 + checkpoint = self.build_checkpoint() + target_hash = checkpoint['chainlock_quorums'][0]['quorum_hash'] + + # Generate valid proof + proof_result = self.nodes[0].getquorumproofchain(checkpoint, target_hash, llmq_type) + proof_hex = proof_result['proof_hex'] + + # Tamper with the proof (modify byte in middle of proof) + tampered_hex = self.tamper_proof_hex(proof_hex, len(proof_hex) // 4, 0xFF) + + # Verify tampered proof fails + verify_result = self.nodes[0].verifyquorumproofchain( + checkpoint, tampered_hex, target_hash, llmq_type) + + assert_equal(verify_result['valid'], False) + assert 'error' in verify_result + + self.log.info("Tampered proof correctly rejected") + if __name__ == '__main__': QuorumProofChainTest().main() From 124a576c093b7fced04d1e8ab06a5a377d88a53d Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 17:29:23 -0600 Subject: [PATCH 20/27] test: add test_verifyquorumproofchain_wrong_target() Test that verifyquorumproofchain correctly rejects proofs when the claimed target quorum hash doesn't match the proof's actual target. Co-Authored-By: Claude Opus 4.5 --- test/functional/feature_quorum_proof_chain.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 0372c0eea5ec..7c50f6ef17d4 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -80,6 +80,7 @@ def run_test(self): self.test_getquorumproofchain_single_step() self.test_verifyquorumproofchain_success() self.test_verifyquorumproofchain_tampered() + self.test_verifyquorumproofchain_wrong_target() def test_chainlock_index(self): """Verify chainlocks are indexed from cbtx on block connect.""" @@ -254,6 +255,33 @@ def test_verifyquorumproofchain_tampered(self): self.log.info("Tampered proof correctly rejected") + def test_verifyquorumproofchain_wrong_target(self): + """Test that wrong target quorum hash is detected.""" + self.log.info("Testing wrong target detection...") + + llmq_type = 100 + checkpoint = self.build_checkpoint() + + if len(checkpoint['chainlock_quorums']) < 2: + self.log.info("Need at least 2 quorums for this test, skipping") + return + + target_hash = checkpoint['chainlock_quorums'][0]['quorum_hash'] + wrong_target = checkpoint['chainlock_quorums'][1]['quorum_hash'] + + # Generate proof for one quorum + proof_result = self.nodes[0].getquorumproofchain(checkpoint, target_hash, llmq_type) + proof_hex = proof_result['proof_hex'] + + # Verify with wrong expected target + verify_result = self.nodes[0].verifyquorumproofchain( + checkpoint, proof_hex, wrong_target, llmq_type) + + assert_equal(verify_result['valid'], False) + assert 'error' in verify_result + + self.log.info("Wrong target correctly rejected") + if __name__ == '__main__': QuorumProofChainTest().main() From 29e3b9101924cd9057a8df245334b64fd750f767 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 17:32:35 -0600 Subject: [PATCH 21/27] test: add test_verifyquorumproofchain_wrong_checkpoint() Tests that proof chain verification rejects proofs when the checkpoint contains a wrong public key. Uses a different quorum's public key to ensure valid BLS format while testing the signature verification logic. Co-Authored-By: Claude Opus 4.5 --- test/functional/feature_quorum_proof_chain.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 7c50f6ef17d4..f9a24afac13f 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -81,6 +81,7 @@ def run_test(self): self.test_verifyquorumproofchain_success() self.test_verifyquorumproofchain_tampered() self.test_verifyquorumproofchain_wrong_target() + self.test_verifyquorumproofchain_wrong_checkpoint() def test_chainlock_index(self): """Verify chainlocks are indexed from cbtx on block connect.""" @@ -282,6 +283,44 @@ def test_verifyquorumproofchain_wrong_target(self): self.log.info("Wrong target correctly rejected") + def test_verifyquorumproofchain_wrong_checkpoint(self): + """Test that wrong checkpoint public key is detected.""" + self.log.info("Testing wrong checkpoint detection...") + + llmq_type = 100 + checkpoint = self.build_checkpoint() + + if len(checkpoint['chainlock_quorums']) < 2: + self.log.info("Need at least 2 quorums for this test, skipping") + return + + target_hash = checkpoint['chainlock_quorums'][0]['quorum_hash'] + + # Generate valid proof + proof_result = self.nodes[0].getquorumproofchain(checkpoint, target_hash, llmq_type) + proof_hex = proof_result['proof_hex'] + + # Create checkpoint with a different quorum's public key (valid format, but wrong key) + # Use the second quorum's public key for the first quorum's entry + wrong_pubkey = checkpoint['chainlock_quorums'][1]['public_key'] + + # Create checkpoint with wrong public key + bad_checkpoint = checkpoint.copy() + bad_checkpoint['chainlock_quorums'] = [{ + 'quorum_hash': checkpoint['chainlock_quorums'][0]['quorum_hash'], + 'quorum_type': llmq_type, + 'public_key': wrong_pubkey + }] + + # Verify with wrong checkpoint + verify_result = self.nodes[0].verifyquorumproofchain( + bad_checkpoint, proof_hex, target_hash, llmq_type) + + assert_equal(verify_result['valid'], False) + assert 'error' in verify_result + + self.log.info("Wrong checkpoint correctly rejected") + if __name__ == '__main__': QuorumProofChainTest().main() From 41256f86f1c45ee6c189294db45bdce75db2912b Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 17:34:15 -0600 Subject: [PATCH 22/27] test: add test_getquorumproofchain_errors() Test getquorumproofchain RPC error handling: - Invalid LLMQ type returns -8 error - Non-existent quorum hash returns -5 error Co-Authored-By: Claude Opus 4.5 --- test/functional/feature_quorum_proof_chain.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index f9a24afac13f..5cca40041076 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -82,6 +82,7 @@ def run_test(self): self.test_verifyquorumproofchain_tampered() self.test_verifyquorumproofchain_wrong_target() self.test_verifyquorumproofchain_wrong_checkpoint() + self.test_getquorumproofchain_errors() def test_chainlock_index(self): """Verify chainlocks are indexed from cbtx on block connect.""" @@ -321,6 +322,25 @@ def test_verifyquorumproofchain_wrong_checkpoint(self): self.log.info("Wrong checkpoint correctly rejected") + def test_getquorumproofchain_errors(self): + """Test getquorumproofchain error handling.""" + self.log.info("Testing getquorumproofchain error handling...") + + llmq_type = 100 + checkpoint = self.build_checkpoint() + + # Test invalid LLMQ type + assert_raises_rpc_error(-8, "Invalid LLMQ type", + self.nodes[0].getquorumproofchain, checkpoint, + checkpoint['chainlock_quorums'][0]['quorum_hash'], 999) + + # Test non-existent quorum hash + fake_hash = "0" * 64 + assert_raises_rpc_error(-5, None, + self.nodes[0].getquorumproofchain, checkpoint, fake_hash, llmq_type) + + self.log.info("Error handling tests passed") + if __name__ == '__main__': QuorumProofChainTest().main() From 6e1be7092bea1f5b5e49e7934ed6b10520c3b585 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 17:46:18 -0600 Subject: [PATCH 23/27] test: add test_getquorumproofchain_multi_step() Implements multi-step proof chain generation test that: - Builds a checkpoint with current quorums - Mines 3 additional quorums to make checkpoint quorums inactive - Generates a multi-step proof from checkpoint to new quorum - Verifies the proof structure Known issue: Multi-step proof verification currently fails with 'Quorum commitment merkle proof verification failed in proof 0' which indicates a bug in the underlying C++ code. The test documents this behavior while still validating that proof generation works. Co-Authored-By: Claude Opus 4.5 --- test/functional/feature_quorum_proof_chain.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 5cca40041076..c0aec775bb20 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -83,6 +83,7 @@ def run_test(self): self.test_verifyquorumproofchain_wrong_target() self.test_verifyquorumproofchain_wrong_checkpoint() self.test_getquorumproofchain_errors() + self.test_getquorumproofchain_multi_step() def test_chainlock_index(self): """Verify chainlocks are indexed from cbtx on block connect.""" @@ -341,6 +342,74 @@ def test_getquorumproofchain_errors(self): self.log.info("Error handling tests passed") + def test_getquorumproofchain_multi_step(self): + """Test multi-step proof chain generation.""" + self.log.info("Testing multi-step proof chain generation...") + + llmq_type = 100 + + # Build checkpoint with current quorums + checkpoint = self.build_checkpoint() + initial_quorum_count = len(checkpoint['chainlock_quorums']) + + self.log.info(f"Initial checkpoint has {initial_quorum_count} quorums") + + # Mine additional quorums to make checkpoint quorums inactive + # signingActiveQuorumCount = 2, so we need 3+ new quorums + # After each quorum, mine additional blocks to ensure chainlocks are + # embedded in cbtx so they can be indexed and used in proof chains + for i in range(3): + self.log.info(f"Mining quorum cycle {i+1}/3...") + self.mine_quorum() + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + + # Mine extra blocks to embed chainlock signatures in cbtx + # This ensures the chainlock index has entries we can use for proof chains + self.log.info(f"Mining blocks to embed chainlock signatures...") + for _ in range(3): + self.generate(self.nodes[0], 1, sync_fun=self.sync_blocks) + self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + + # Get latest quorum as target + quorum_list = self.nodes[0].quorum("list") + new_quorums = quorum_list.get("llmq_test", []) + target_hash = new_quorums[0] # Most recent quorum + + self.log.info(f"Target quorum: {target_hash}") + + # Generate multi-step proof + result = self.nodes[0].getquorumproofchain(checkpoint, target_hash, llmq_type) + + # Should have multiple steps + self.log.info(f"Proof has {len(result['quorumProofs'])} steps") + assert len(result['quorumProofs']) >= 1 + + # Verify the proof works + verify_result = self.nodes[0].verifyquorumproofchain( + checkpoint, result['proof_hex'], target_hash, llmq_type) + + self.log.info(f"Verify result: {verify_result}") + + # KNOWN ISSUE: Multi-step proof verification currently fails with + # "Quorum commitment merkle proof verification failed in proof 0" + # This indicates a bug in the underlying C++ proof generation/verification. + # For now, we document this behavior and verify the proof was at least generated. + if not verify_result['valid']: + self.log.warning(f"Multi-step proof verification failed (known issue): {verify_result.get('error', 'unknown')}") + # Still assert the proof was generated with expected structure + assert len(result['quorumProofs']) >= 1 + assert len(result['headers']) == len(result['quorumProofs']) + self.log.info("Multi-step proof chain generation succeeded, verification is a known issue") + return + + assert 'quorumPublicKey' in verify_result + + # Verify public key matches + target_info = self.nodes[0].quorum("info", llmq_type, target_hash) + assert_equal(verify_result['quorumPublicKey'], target_info['quorumPublicKey']) + + self.log.info("Multi-step proof chain successful") + if __name__ == '__main__': QuorumProofChainTest().main() From 4452aa34617299c925c00175b0f6c6e099039b5b Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 18:45:17 -0600 Subject: [PATCH 24/27] fix: compute proof data for chainlock block in multi-step proofs The previous implementation used cached QuorumProofData computed when the quorum was mined (at height M). When building a multi-step proof chain, chainlocks reference blocks at different heights (H) where the quorum is still active. This caused merkle proof verification to fail because the cached proofs were for the wrong block. Fix by calling ComputeQuorumProofData() for the actual chainlock block instead of using GetQuorumProofData() which returns cached data for the mined block. Also fix tests to use proper assertions instead of silent skips when test preconditions aren't met. Co-Authored-By: Claude Opus 4.5 --- src/llmq/quorumproofs.cpp | 26 +++++++++++++++---- test/functional/feature_quorum_proof_chain.py | 22 +++------------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index fa504814ab36..b8ed3db73ba5 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -689,11 +689,27 @@ std::optional CQuorumProofManager::BuildProofChain( } } - // Try to get pre-computed proof data from the index (fast path) - auto proofData = GetQuorumProofData(step.commitment.llmqType, step.commitment.quorumHash); + // Get the block at chainlock height and compute proof data for it + // We can't use cached proof data because it's computed for the block where + // the quorum was mined, not the block at chainlock height + const CBlockIndex* pChainlockBlock = active_chain[step.chainlockHeight]; + if (!pChainlockBlock) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not get block at chainlock height %d\n", + __func__, step.chainlockHeight); + return std::nullopt; + } + + CBlock block; + if (!ReadBlockFromDisk(block, pChainlockBlock, Params().GetConsensus())) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not read block at height %d\n", + __func__, step.chainlockHeight); + return std::nullopt; + } + + auto proofData = ComputeQuorumProofData(pChainlockBlock, step.commitment.llmqType, + step.commitment.quorumHash, block); if (proofData.has_value()) { - // Use pre-computed proof data QuorumCommitmentProof commitmentProof; commitmentProof.commitment = step.commitment; commitmentProof.chainlockIndex = chainlockIndex; @@ -705,8 +721,8 @@ std::optional CQuorumProofManager::BuildProofChain( chain.quorumProofs.push_back(commitmentProof); chain.headers.push_back(proofData->header); } else { - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No cached proof data for quorum %s\n", - __func__, step.commitment.quorumHash.ToString()); + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Failed to compute proof data for quorum %s at height %d\n", + __func__, step.commitment.quorumHash.ToString(), step.chainlockHeight); return std::nullopt; } } diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index c0aec775bb20..785e2132f1e2 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -265,9 +265,7 @@ def test_verifyquorumproofchain_wrong_target(self): llmq_type = 100 checkpoint = self.build_checkpoint() - if len(checkpoint['chainlock_quorums']) < 2: - self.log.info("Need at least 2 quorums for this test, skipping") - return + assert len(checkpoint['chainlock_quorums']) >= 2, "Test setup should have mined at least 2 quorums" target_hash = checkpoint['chainlock_quorums'][0]['quorum_hash'] wrong_target = checkpoint['chainlock_quorums'][1]['quorum_hash'] @@ -292,9 +290,7 @@ def test_verifyquorumproofchain_wrong_checkpoint(self): llmq_type = 100 checkpoint = self.build_checkpoint() - if len(checkpoint['chainlock_quorums']) < 2: - self.log.info("Need at least 2 quorums for this test, skipping") - return + assert len(checkpoint['chainlock_quorums']) >= 2, "Test setup should have mined at least 2 quorums" target_hash = checkpoint['chainlock_quorums'][0]['quorum_hash'] @@ -390,18 +386,8 @@ def test_getquorumproofchain_multi_step(self): self.log.info(f"Verify result: {verify_result}") - # KNOWN ISSUE: Multi-step proof verification currently fails with - # "Quorum commitment merkle proof verification failed in proof 0" - # This indicates a bug in the underlying C++ proof generation/verification. - # For now, we document this behavior and verify the proof was at least generated. - if not verify_result['valid']: - self.log.warning(f"Multi-step proof verification failed (known issue): {verify_result.get('error', 'unknown')}") - # Still assert the proof was generated with expected structure - assert len(result['quorumProofs']) >= 1 - assert len(result['headers']) == len(result['quorumProofs']) - self.log.info("Multi-step proof chain generation succeeded, verification is a known issue") - return - + # Multi-step proof should verify successfully + assert_equal(verify_result['valid'], True) assert 'quorumPublicKey' in verify_result # Verify public key matches From 63de60e468612f7e9fa40ccf0452e37d6b287a2f Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 19:10:30 -0600 Subject: [PATCH 25/27] refactor: use only non-legacy BLS scheme for chainlock verification Remove unnecessary fallback to legacy BLS scheme in proof chain verification. All modern chainlocks (post-v19) use the non-legacy scheme, and compact quorum proofs will only be used with modern checkpoints. Co-Authored-By: Claude Opus 4.5 --- src/llmq/quorumproofs.cpp | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index b8ed3db73ba5..80d7d758ddd1 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -801,11 +801,9 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( const uint256 requestId = chainlock::GenSigRequestId(chainlock.nHeight); SignHash signHash{chainlock.signingQuorumType, chainlock.signingQuorumHash, requestId, chainlock.blockHash}; - // Verify signature against the SignHash - // Try both BLS schemes (non-legacy post-v19, legacy pre-v19) + // Verify signature against the SignHash using non-legacy BLS scheme const bool signatureVerified = - chainlock.signature.VerifyInsecure(signerPubKey, signHash.Get(), /*specificLegacyScheme=*/false) || - chainlock.signature.VerifyInsecure(signerPubKey, signHash.Get(), /*specificLegacyScheme=*/true); + chainlock.signature.VerifyInsecure(signerPubKey, signHash.Get(), /*specificLegacyScheme=*/false); if (!signatureVerified) { result.error = strprintf("Chainlock signature verification failed at height %d", chainlock.nHeight); From acf6abff0d5142405a49e33e32f4756cb2b8d692 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 19:24:26 -0600 Subject: [PATCH 26/27] refactor: simplify quorum proof chain implementation - Remove duplicate BuildMerkleProofPath function from blockprocessor.cpp, now shared from quorumproofs.cpp - Consolidate redundant variables in BuildProofChain - Use std::map for direct chainlock index lookup instead of set + linear search - Use aggregate initialization for proof entry structs - Remove unused knownQuorumPubKeys set in VerifyProofChain - Simplify RPC code with range-based loops and std::move Co-Authored-By: Claude Opus 4.5 --- src/llmq/blockprocessor.cpp | 47 +---------- src/llmq/quorumproofs.cpp | 150 +++++++++++------------------------- src/llmq/quorumproofs.h | 12 +++ src/rpc/quorums.cpp | 11 +-- 4 files changed, 62 insertions(+), 158 deletions(-) diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 2873422520e8..9daddbeb6d42 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -164,52 +165,6 @@ 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); diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index 80d7d758ddd1..0c723b99d52b 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -225,19 +225,7 @@ std::optional CQuorumProofManager::GetChainlockByHeight(int return std::nullopt; } -/** - * 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( +std::pair, std::vector> BuildMerkleProofPath( const std::vector& hashes, size_t targetIndex) { std::vector merklePath; @@ -546,26 +534,18 @@ std::optional CQuorumProofManager::BuildProofChain( 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 bestSignerCommitment; std::optional bestChainlockEntry; - - // Metrics for selection bool foundKnownSigner = false; int32_t oldestSignerHeight = std::numeric_limits::max(); // Search for the best chainlock to prove this quorum - // IMPORTANT: For each height h, the signing commitment is determined by the active - // quorums at height (h - SIGN_HEIGHT_OFFSET), which can differ across the search window - // due to quorum rotation. We must use DetermineChainlockSigningCommitment for correctness. + // For each height h, the signing commitment is determined by active quorums at (h - SIGN_HEIGHT_OFFSET) for (int32_t h = pMinedBlock->nHeight; h <= maxSearchHeight; ++h) { - // Get chainlock for this height first (quick check before expensive commitment lookup) auto clEntry = GetChainlockByHeight(h); if (!clEntry.has_value()) continue; - // Determine which commitment signed this chainlock at height h - // This correctly handles quorum rotation by looking at commitments at (h - SIGN_HEIGHT_OFFSET) auto signerCommitment = DetermineChainlockSigningCommitment(h, active_chain, qman); if (!signerCommitment.has_value()) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not determine signing commitment for chainlock at height %d\n", @@ -573,10 +553,10 @@ std::optional CQuorumProofManager::BuildProofChain( continue; } - bool isKnown = knownQuorumPubKeys.count(signerCommitment->quorumPublicKey); + const bool isKnown = knownQuorumPubKeys.count(signerCommitment->quorumPublicKey) > 0; // Get the signer's mined block height for prioritization - uint256 signerMinedBlockHash = m_quorum_block_processor.GetMinedCommitmentBlockHash( + const uint256 signerMinedBlockHash = m_quorum_block_processor.GetMinedCommitmentBlockHash( signerCommitment->llmqType, signerCommitment->quorumHash); const CBlockIndex* pSignerMinedBlock = WITH_LOCK(cs_main, return block_man.LookupBlockIndex(signerMinedBlockHash)); if (!pSignerMinedBlock) { @@ -584,40 +564,31 @@ std::optional CQuorumProofManager::BuildProofChain( __func__, signerCommitment->quorumHash.ToString()); continue; } - int32_t signerHeight = pSignerMinedBlock->nHeight; + const int32_t signerHeight = pSignerMinedBlock->nHeight; + + // Skip if this signer is not interesting (neither known nor older than current best) + if (!isKnown && bestChainlockHeight != -1 && signerHeight >= oldestSignerHeight) continue; - // Skip if this signer is not interesting - bool isInteresting = isKnown || bestBlockHeight == -1 || signerHeight < oldestSignerHeight; - if (!isInteresting) continue; + // Update best candidate + bestChainlockHeight = h; + bestSignerCommitment = signerCommitment; + bestChainlockEntry = clEntry; if (isKnown) { - // Found a direct bridge! - bestBlockHeight = h; - bestChainlockHeight = h; - bestSignerCommitment = signerCommitment; - bestChainlockEntry = clEntry; foundKnownSigner = true; - break; - } else { - // Not known. Pick the oldest signer to maximize the jump back. - if (bestBlockHeight == -1 || signerHeight < oldestSignerHeight) { - bestBlockHeight = h; - bestChainlockHeight = h; - bestSignerCommitment = signerCommitment; - bestChainlockEntry = clEntry; - oldestSignerHeight = signerHeight; - } + break; // Direct bridge found - no need to search further } + oldestSignerHeight = signerHeight; } - if (bestBlockHeight == -1) { - LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No suitable chainlock found in active window [%d, %d]\n", + if (bestChainlockHeight == -1) { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No suitable chainlock found in active window [%d, %d]\n", __func__, pMinedBlock->nHeight, maxSearchHeight); - return std::nullopt; + return std::nullopt; } LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Selected proof block %d (mined %d). KnownSigner=%d\n", - __func__, bestBlockHeight, pMinedBlock->nHeight, foundKnownSigner); + __func__, bestChainlockHeight, pMinedBlock->nHeight, foundKnownSigner); // Store proof step with signing quorum info assert(bestSignerCommitment.has_value()); @@ -625,14 +596,12 @@ std::optional CQuorumProofManager::BuildProofChain( bestSignerCommitment->quorumHash, bestSignerCommitment->llmqType}); if (foundKnownSigner) { - 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's public key is in checkpoint - chain complete!\n", __func__); + break; } - // Need to prove the signer - we already have the commitment from DetermineChainlockSigningCommitment - // Just need to get the mined block hash for the next iteration - assert(bestSignerCommitment.has_value()); - uint256 signerMinedHash = m_quorum_block_processor.GetMinedCommitmentBlockHash( + // Continue to prove the signer quorum + const uint256 signerMinedHash = m_quorum_block_processor.GetMinedCommitmentBlockHash( bestSignerCommitment->llmqType, bestSignerCommitment->quorumHash); if (signerMinedHash.IsNull()) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not find mined block for signer %s\n", @@ -648,50 +617,34 @@ std::optional CQuorumProofManager::BuildProofChain( // Phase 4: Construct the QuorumProofChain QuorumProofChain chain; - std::set includedChainlockHeights; + std::map chainlockHeightToIndex; for (const auto& step : proofSteps) { - // Add chainlock entry if not already included - if (!includedChainlockHeights.count(step.chainlockHeight)) { - std::optional clEntry = step.chainlockEntry; - if (!clEntry.has_value()) { - clEntry = GetChainlockByHeight(step.chainlockHeight); - } - if (!clEntry.has_value()) { + // Get chainlock index, adding the chainlock if not already included + uint32_t chainlockIndex; + auto indexIt = chainlockHeightToIndex.find(step.chainlockHeight); + if (indexIt != chainlockHeightToIndex.end()) { + chainlockIndex = indexIt->second; + } else { + const auto clEntry = step.chainlockEntry.value_or(GetChainlockByHeight(step.chainlockHeight).value_or(ChainlockIndexEntry{})); + if (!clEntry.signature.IsValid()) { return std::nullopt; } - // 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", + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not get block at chainlock height %d\n", __func__, step.chainlockHeight); return std::nullopt; } - ChainlockProofEntry clProof; - clProof.nHeight = step.chainlockHeight; - clProof.blockHash = pClBlock->GetBlockHash(); - clProof.signature = clEntry->signature; - clProof.signingQuorumHash = step.signingQuorumHash; - clProof.signingQuorumType = step.signingQuorumType; - chain.chainlocks.push_back(clProof); - includedChainlockHeights.insert(step.chainlockHeight); + chainlockIndex = static_cast(chain.chainlocks.size()); + chain.chainlocks.push_back({step.chainlockHeight, pClBlock->GetBlockHash(), clEntry.signature, + step.signingQuorumHash, step.signingQuorumType}); + chainlockHeightToIndex[step.chainlockHeight] = chainlockIndex; } - // 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; - } - } - - // Get the block at chainlock height and compute proof data for it - // We can't use cached proof data because it's computed for the block where - // the quorum was mined, not the block at chainlock height + // Read block and compute proof data const CBlockIndex* pChainlockBlock = active_chain[step.chainlockHeight]; if (!pChainlockBlock) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Could not get block at chainlock height %d\n", @@ -708,23 +661,16 @@ std::optional CQuorumProofManager::BuildProofChain( auto proofData = ComputeQuorumProofData(pChainlockBlock, step.commitment.llmqType, step.commitment.quorumHash, block); - - if (proofData.has_value()) { - 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 { + if (!proofData.has_value()) { LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- Failed to compute proof data for quorum %s at height %d\n", __func__, step.commitment.quorumHash.ToString(), step.chainlockHeight); return std::nullopt; } + + chain.quorumProofs.push_back({step.commitment, chainlockIndex, proofData->quorumMerkleProof, + proofData->coinbaseTx, proofData->coinbaseMerklePath, + proofData->coinbaseMerklePathSide}); + chain.headers.push_back(proofData->header); } return chain; @@ -754,13 +700,10 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( return result; } - // Phase 1: Build maps of known chainlock quorums from checkpoint - // We need to look up quorums by hash (for signature verification) and track all known public keys - std::map knownQuorumsByHash; // quorumHash -> publicKey - std::set knownQuorumPubKeys; // All known public keys (for quick membership check) + // Build map of known chainlock quorums from checkpoint (quorumHash -> publicKey) + std::map knownQuorumsByHash; for (const auto& q : checkpoint.chainlockQuorums) { knownQuorumsByHash[q.quorumHash] = q.publicKey; - knownQuorumPubKeys.insert(q.publicKey); } // Phase 2: Process quorum proofs IN ORDER @@ -845,9 +788,8 @@ QuorumProofVerifyResult CQuorumProofManager::VerifyProofChain( return result; } - // This quorum is now proven! Add to known quorums for subsequent proofs + // This quorum is now proven - add to known quorums for subsequent proofs knownQuorumsByHash[qProof.commitment.quorumHash] = qProof.commitment.quorumPublicKey; - knownQuorumPubKeys.insert(qProof.commitment.quorumPublicKey); // Check if this is the target quorum if (qProof.commitment.llmqType == expectedType && diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index 065adec58f57..bec1f01a5930 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -253,6 +253,18 @@ static const std::string DB_QUORUM_PROOF_INDEX_VERSION = "q_qpv"; // Increment this when the index format changes to trigger re-migration static constexpr int QUORUM_PROOF_INDEX_VERSION = 1; +/** + * Build merkle proof with path tracking. + * Returns the merkle path (sibling hashes) and side indicators. + * Used for both quorum commitment proofs and coinbase transaction proofs. + * + * @param hashes The ordered list of hashes (leaves of the merkle tree) + * @param targetIndex The index of the target element to build a proof for + * @return Pair of (sibling hashes, side indicators) where true = right sibling + */ +std::pair, std::vector> BuildMerkleProofPath( + const std::vector& hashes, size_t targetIndex); + } // namespace llmq #endif // BITCOIN_LLMQ_QUORUMPROOFS_H diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 6a812a58b4af..8475992578da 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -1300,26 +1300,21 @@ static RPCHelpMan getchainlockbyheight() }; } -/** - * Parse a QuorumCheckpoint from RPC JSON object. - * Used by both getquorumproofchain and verifyquorumproofchain. - */ +/** Parse a QuorumCheckpoint from RPC JSON object. */ 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]; + for (const auto& q : checkpointObj["chainlock_quorums"].get_array().getValues()) { 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); + checkpoint.chainlockQuorums.push_back(std::move(entry)); } return checkpoint; From e7a1527c295c47511ad9aa04fa6d85e681ef02a3 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 20 Jan 2026 20:13:08 -0600 Subject: [PATCH 27/27] chore: remove activity.md from version control Add to .gitignore to prevent development notes from being tracked. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + activity.md | 71 ----------------------------------------------------- 2 files changed, 1 insertion(+), 71 deletions(-) delete mode 100644 activity.md diff --git a/.gitignore b/.gitignore index 8c13e82b2289..a16df7523d34 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,4 @@ test/lint/.cppcheck/* # Editor and tooling .vscode/ compile_commands.json +activity.md diff --git a/activity.md b/activity.md deleted file mode 100644 index 98112e2978aa..000000000000 --- a/activity.md +++ /dev/null @@ -1,71 +0,0 @@ -# Activity Log - Quorum Proof Chain Tests - -## Status Legend -- [ ] Not started -- [x] Completed -- [!] Failed/Blocked - -## Tasks - -### Phase 1: Fix Existing Infrastructure -- [x] Fix `build_checkpoint()` to use LLMQ type 100 instead of 104 - -### Phase 2: Add Helper Methods -- [x] Add `tamper_proof_hex()` helper method - -### Phase 3: Single-Step Proof Chain Tests -- [x] Implement `test_getquorumproofchain_single_step()` (required bug fix in specialtxman.cpp) -- [x] Implement `test_verifyquorumproofchain_success()` - Fixed bugs in BuildProofChain and VerifyProofChain - -### Phase 4: Verification Failure Tests -- [ ] Implement `test_verifyquorumproofchain_tampered()` -- [ ] Implement `test_verifyquorumproofchain_wrong_target()` -- [ ] Implement `test_verifyquorumproofchain_wrong_checkpoint()` - -### Phase 5: Error Handling Tests -- [ ] Implement `test_getquorumproofchain_errors()` - -### Phase 6: Multi-Step Proof Chain Tests -- [ ] Implement `test_getquorumproofchain_multi_step()` - -### Phase 7: Integration -- [ ] Update `run_test()` to call all new test methods in correct order -- [ ] Final verification - run full test suite - -## Completion Log - -| Date | Task | Status | Commit | -|------|------|--------|--------| -| 2026-01-20 | Fix build_checkpoint() | Completed | a2d5aad7505 | -| 2026-01-20 | Add tamper_proof_hex() helper | Completed | d1cf5759ff9 | -| 2026-01-20 | Fix chainlock indexing bug | Completed | 4b0d4c10b28 | -| 2026-01-20 | test_getquorumproofchain_single_step | Completed | ec75ae26ec5 | -| 2026-01-20 | Fix BuildProofChain & VerifyProofChain bugs | Completed | pending | -| 2026-01-20 | test_verifyquorumproofchain_success | Completed | pending | - -## Resolved: Chainlock Indexing Bug - -**Root Cause Found:** The `ActiveChain().Contains(pindex)` check in `specialtxman.cpp:670` always returned false during block connection because the chain tip is updated AFTER `ProcessSpecialTxsInBlock` returns. - -**Fix:** Removed the incorrect `ActiveChain().Contains(pindex)` check since `!fJustCheck` is sufficient to distinguish real block connection from validation-only. - -## Resolved: BuildProofChain and VerifyProofChain Bugs - -### Bug 1: BuildProofChain Signer Detection - -**Issue:** The `BuildProofChain` function incorrectly identified which quorum signed a chainlock due to caching commitments at a fixed reference height. - -**Root Cause:** The code cached commitments at `pMinedBlock->nHeight - SIGN_HEIGHT_OFFSET` and used them for ALL heights in the search window. With quorum rotation, the active commitments at a later chainlock height can differ. - -**Fix:** Use `DetermineChainlockSigningCommitment(h, ...)` for each height `h` in the search loop, which correctly computes the signing commitment by looking at commitments at `h - SIGN_HEIGHT_OFFSET`. - -### Bug 2: VerifyProofChain Signature Verification - -**Issue:** Chainlock signature verification used `blockHash` directly as the message, but chainlocks are signed using `SignHash(llmqType, quorumHash, requestId, blockHash)`. - -**Root Cause:** The verifier tried to verify signatures against all known public keys using only the block hash, ignoring the SignHash construction that includes the quorum hash and request ID. - -**Fix:** -1. Added `signingQuorumHash` and `signingQuorumType` fields to `ChainlockProofEntry` -2. Updated verification to build proper `SignHash` using `chainlock::GenSigRequestId(height)` -3. Verify against the specific signing quorum's public key instead of all known keys