diff --git a/src/Makefile.am b/src/Makefile.am index 1c5fd1936399..df0b5be6e919 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -282,6 +282,8 @@ 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 \ llmq/signhash.h \ @@ -557,6 +559,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..1b21fb050a14 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -140,6 +140,8 @@ BITCOIN_TESTS =\ test/llmq_snapshot_tests.cpp \ 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/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..0c39c12fb366 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)) { + int 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()) { + int 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/init.cpp b/src/init.cpp index 312b38486423..3beea3ad1c73 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -99,6 +99,7 @@ #include #include #include +#include #include #include #include @@ -2171,6 +2172,15 @@ 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); + // 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); node.dstxman = std::make_unique(); diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index 86b9617ff004..2873422520e8 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -162,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); @@ -230,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; @@ -399,6 +519,9 @@ bool CQuorumBlockProcessor::UndoBlock(const CBlock& block, gsl::not_null 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/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/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 new file mode 100644 index 000000000000..aa6acba6e279 --- /dev/null +++ b/src/llmq/quorumproofs.cpp @@ -0,0 +1,1174 @@ +// 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 +#include +#include + +#include +#include + +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 +// + +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; +} + +/** + * 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 CBlock* pBlock, + CommitmentHashCache* pHashCache) const +{ + if (pindex == nullptr || pindex->pprev == nullptr) { + return std::nullopt; + } + + // 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; + uint256 targetCommitmentHash; + bool targetFound = false; + + for (const auto& [type, blockIndexes] : commitmentsMap) { + for (const auto* blockIndex : blockIndexes) { + 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 && blockHash == quorumHash) { + targetCommitmentHash = commitmentHash; + targetFound = true; + } + } + } + + // Now add commitments from the current block's transactions (matching CalcCbTxMerkleRootQuorums logic) + // This is necessary because GetMinedAndActiveCommitmentsUntilBlock uses pindexPrev + 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]; + 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; + } + + // 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; +} + +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 + 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, signingCommitment->quorumHash.ToString()); + return height; + } + } + return -1; +} + +std::optional CQuorumProofManager::DetermineChainlockSigningCommitment( + 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 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 SelectCommitmentForSigning which avoids building full quorum objects + return SelectCommitmentForSigning(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 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 { + CFinalCommitment commitment; + int32_t chainlockHeight; + std::optional chainlockEntry; + }; + std::vector proofSteps; + std::set visitedQuorums; // Cycle detection + + // Start with the target quorum + // 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; + + while (true) { + // Cycle detection + if (visitedQuorums.count(currentCommitment.quorumHash)) { + return std::nullopt; // Cycle detected - invalid chain + } + visitedQuorums.insert(currentCommitment.quorumHash); + + // 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; + } + + // Look up the block where the commitment was actually mined + 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__, currentMinedBlockHash.ToString(), currentCommitment.quorumHash.ToString()); + return std::nullopt; + } + + // 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 signed by the oldest possible quorum (maximize jump) + + 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; + size_t bestSignerIndex = 0; + 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; + } + + // 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) { + // 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); + continue; + } + size_t signerIdx = signerIdxOpt.value(); + 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 (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; + 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; + bestSignerIndex = signerIdx; + bestChainlockEntry = clEntry; + oldestSignerHeight = signerHeight; + } + } + } + + 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; + } + + 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}); + + 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()); + return std::nullopt; + } + currentCommitment = std::move(signerCommitment); + currentMinedBlockHash = signerMinedHash; + } + + // 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)) { + std::optional clEntry = step.chainlockEntry; + if (!clEntry.has_value()) { + clEntry = GetChainlockByHeight(step.chainlockHeight); + } + if (!clEntry.has_value()) { + 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", + __func__, step.chainlockHeight); + 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); + } + + // 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; + } + } + + // 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 { + LogPrint(BCLog::LLMQ, "CQuorumProofManager::%s -- No cached proof data for quorum %s\n", + __func__, step.commitment.quorumHash.ToString()); + return std::nullopt; + } + } + + 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; + } + + // 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 = strprintf("Invalid chainlock index %d", 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 = strprintf("Invalid chainlock signature format at height %d", 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 = strprintf("Chainlock signature verification failed at height %d - signature does not match any known quorum key", chainlock.nHeight); + 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 = strprintf("Missing coinbase transaction in proof %d", proofIdx); + return result; + } + + const uint256 coinbaseTxHash = qProof.coinbaseTx->GetHash(); + 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; + } + + // Extract merkleRootQuorums from cbtx + auto opt_cbtx = GetTxPayload(*qProof.coinbaseTx); + if (!opt_cbtx.has_value()) { + result.error = strprintf("Invalid coinbase transaction payload in proof %d", 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 = strprintf("Quorum commitment merkle proof verification failed in proof %d", 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; +} + +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); +} + +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 new file mode 100644 index 000000000000..fddc1baa6d1d --- /dev/null +++ b/src/llmq/quorumproofs.h @@ -0,0 +1,256 @@ +// 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 CChainParams; +class CEvoDB; + +namespace node { +class BlockManager; +} // namespace node + +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; +}; + +/** + * 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; +}; + +// 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. + */ +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]] std::optional DetermineChainlockSigningCommitment( + 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; + + // 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) {} + + 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 CBlock* pBlock = nullptr, + CommitmentHashCache* pHashCache = nullptr) 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 node::BlockManager& block_man) const; + + // Proof Chain Verification + [[nodiscard]] QuorumProofVerifyResult VerifyProofChain( + const QuorumCheckpoint& checkpoint, + 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); + + // 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 +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 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; + +// 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; + +// 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 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, 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/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 f82ec32def8d..6a812a58b4af 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,232 @@ 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 + 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); + 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(), 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"); + } + + 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", + "'{\"block_hash\":\"0000...\",\"height\":100,\"chainlock_quorums\":[{\"quorum_hash\":\"abcd...\",\"quorum_type\":104,\"public_key\":\"1234...\"}]}' " + "\"\" \"\" 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()); + + 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); + + return verifyResult.ToJson(); +}, + }; +} + void RegisterQuorumsRPCCommands(CRPCTable &tableRPC) { static const CRPCCommand commands[]{ @@ -1269,6 +1496,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_regression_tests.cpp b/src/test/quorum_proofs_regression_tests.cpp new file mode 100644 index 000000000000..0fb36f444048 --- /dev/null +++ b/src/test/quorum_proofs_regression_tests.cpp @@ -0,0 +1,202 @@ +// 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); +} + +// 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"); + 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 mismatched headers/proofs count + llmq::QuorumProofChain chain; + + // Add 2 headers + 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; + header2.hashPrevBlock = 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 only 1 quorum proof (mismatch with 2 headers) + 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); + + // 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/src/test/quorum_proofs_tests.cpp b/src/test/quorum_proofs_tests.cpp new file mode 100644 index 000000000000..73cd8250779a --- /dev/null +++ b/src/test/quorum_proofs_tests.cpp @@ -0,0 +1,389 @@ +// 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) + +// 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) +{ + 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() diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py new file mode 100755 index 000000000000..75f6883e60a3 --- /dev/null +++ b/test/functional/feature_quorum_proof_chain.py @@ -0,0 +1,145 @@ +#!/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, 3) + + 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() + 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, 0, -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, 0, -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