From a0bcedba58308d2b677c00bfa2e9717a53feb8fe Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Sep 2026 00:39:16 -0500 Subject: [PATCH 01/10] feat(chainlock): read historical certificates from coinbases --- src/chainlock/clsig.cpp | 65 ++++++++++++++++++++- src/chainlock/clsig.h | 28 ++++++++- src/evo/cbtx.cpp | 16 ++--- src/evo/cbtx.h | 1 + src/test/llmq_chainlock_tests.cpp | 97 +++++++++++++++++++++++++++++++ 5 files changed, 198 insertions(+), 9 deletions(-) diff --git a/src/chainlock/clsig.cpp b/src/chainlock/clsig.cpp index 6ccdcaae443c..02964a925197 100644 --- a/src/chainlock/clsig.cpp +++ b/src/chainlock/clsig.cpp @@ -4,14 +4,77 @@ #include -#include +#include #include +#include +#include +#include #include +#include +#include +#include +#include #include namespace chainlock { static constexpr std::string_view CLSIG_REQUESTID_PREFIX{"clsig"}; +static constexpr size_t MAX_HISTORICAL_CARRIER_READS{16384}; + +std::optional CoinbaseChainLockReader::Read(int carrier_height) +{ + const auto* carrier = m_chain[carrier_height]; + if (!carrier || carrier_height < Params().GetConsensus().V20Height) return std::nullopt; + if (const auto it = m_cache.find(carrier_height); it != m_cache.end()) return it->second; + if (ShutdownRequested()) throw std::runtime_error("ChainLock lookup interrupted"); + if (m_cache.size() >= MAX_HISTORICAL_CARRIER_READS) + throw std::runtime_error("ChainLock disk-read budget exhausted"); + CBlock block; + if (!node::ReadBlockFromDisk(block, carrier, Params().GetConsensus()) || block.vtx.empty()) { + throw std::runtime_error("Historical ChainLock block data unavailable"); + } + const auto cb = GetTxPayload(*block.vtx[0]); + if (!block.vtx[0]->IsCoinBase() || block.vtx[0]->nType != TRANSACTION_COINBASE || !cb || + cb->nVersion < CCbTx::Version::CLSIG_AND_BALANCE || cb->nHeight != carrier_height) { + throw std::runtime_error("Invalid historical ChainLock coinbase"); + } + if (cb->bestCLHeightDiff >= uint32_t(carrier_height)) { + throw std::runtime_error("Invalid historical coinbase ChainLock height"); + } + const auto chainlock = GetNonNullCoinbaseChainlock(block, carrier_height); + if (!chainlock) { + return m_cache.emplace(carrier_height, std::nullopt).first->second; + } + const int height = carrier_height - int(chainlock->second) - 1; + return m_cache + .emplace(carrier_height, + CoinbaseChainLock{ChainLockSig{height, m_chain[height]->GetBlockHash(), chainlock->first}, carrier}) + .first->second; +} + +std::optional CoinbaseChainLockReader::Find(int minimum_height, int maximum_height) +{ + if (minimum_height < 0 || minimum_height > maximum_height || minimum_height >= m_chain.Height()) + return std::nullopt; + const int first_carrier = std::max(minimum_height + 1, Params().GetConsensus().V20Height); + const int tip_height = m_chain.Height(); + if (first_carrier > tip_height) return std::nullopt; + + // Empty carriers are possible, so the predicate used by a binary search is + // not monotonic. Scan the bounded carrier range instead; Read() keeps the + // request-local disk-read budget and cache. Valid certificates are ordered + // by signed height, so a certificate above the requested maximum ends the + // search. + for (int carrier_height = first_carrier;; ++carrier_height) { + auto entry = Read(carrier_height); + if (entry) { + if (entry->clsig.getHeight() > maximum_height) return std::nullopt; + if (entry->clsig.getHeight() >= minimum_height) return entry; + } + if (carrier_height == tip_height) break; + } + return std::nullopt; +} uint256 GenSigRequestId(const int32_t nHeight) { diff --git a/src/chainlock/clsig.h b/src/chainlock/clsig.h index 5c46c2732a46..8aca03250700 100644 --- a/src/chainlock/clsig.h +++ b/src/chainlock/clsig.h @@ -5,7 +5,11 @@ #ifndef BITCOIN_CHAINLOCK_CLSIG_H #define BITCOIN_CHAINLOCK_CLSIG_H +#include + #include +#include +#include class CChain; class CBlockIndex; @@ -21,7 +25,29 @@ enum class VerifyRecSigStatus : uint8_t; } // namespace llmq namespace chainlock { -struct ChainLockSig; +struct CoinbaseChainLock { + ChainLockSig clsig; + const CBlockIndex* carrier{nullptr}; +}; + +/** Reads historical signatures from a fixed, validated chain view. + * Cache lifetime is one request; missing block data throws instead of implying + * that a certificate does not exist. Disk reads do not hold cs_main. + */ +class CoinbaseChainLockReader +{ + const CChain& m_chain; + std::map> m_cache; + +public: + explicit CoinbaseChainLockReader(const CChain& chain) : + m_chain(chain) + { + } + std::optional Read(int carrier_height); + /** First certificate at or above minimum_height, limited by maximum_height. */ + std::optional Find(int minimum_height, int maximum_height); +}; //! Generate clsig request ID with block height uint256 GenSigRequestId(const int32_t nHeight); diff --git a/src/evo/cbtx.cpp b/src/evo/cbtx.cpp index 856d8d74ee6c..c41e96674a8e 100644 --- a/src/evo/cbtx.cpp +++ b/src/evo/cbtx.cpp @@ -170,16 +170,18 @@ std::optional> GetNonNullCoinbaseChainlock(co return std::nullopt; } - const CTransactionRef cbTx = block.vtx[0]; - const auto opt_cbtx = GetTxPayload(*cbTx); + return GetNonNullCoinbaseChainlock(block, pindex->nHeight); +} - if (!opt_cbtx.has_value()) { +std::optional> GetNonNullCoinbaseChainlock(const CBlock& block, int32_t height) +{ + if (block.vtx.empty() || !block.vtx[0]->IsCoinBase() || block.vtx[0]->nType != TRANSACTION_COINBASE) { return std::nullopt; } - - if (!opt_cbtx->bestCLSignature.IsValid()) { + const auto payload = GetTxPayload(*block.vtx[0]); + if (!payload || payload->nVersion < CCbTx::Version::CLSIG_AND_BALANCE || payload->nHeight != height || + !payload->bestCLSignature.IsValid() || payload->bestCLHeightDiff >= uint32_t(height)) { return std::nullopt; } - - return std::make_pair(opt_cbtx->bestCLSignature, opt_cbtx->bestCLHeightDiff); + return std::make_pair(payload->bestCLSignature, payload->bestCLHeightDiff); } diff --git a/src/evo/cbtx.h b/src/evo/cbtx.h index da6d92f802a8..04afc9e0373c 100644 --- a/src/evo/cbtx.h +++ b/src/evo/cbtx.h @@ -75,5 +75,6 @@ bool CalcCbTxMerkleRootQuorums(const CBlock& block, const CBlockIndex* pindexPre BlockValidationState& state); std::optional> GetNonNullCoinbaseChainlock(const CBlockIndex* pindex); +std::optional> GetNonNullCoinbaseChainlock(const CBlock& block, int32_t height); #endif // BITCOIN_EVO_CBTX_H diff --git a/src/test/llmq_chainlock_tests.cpp b/src/test/llmq_chainlock_tests.cpp index c9244de5f431..58343ea9bcde 100644 --- a/src/test/llmq_chainlock_tests.cpp +++ b/src/test/llmq_chainlock_tests.cpp @@ -21,13 +21,18 @@ #include #include +#include #include +#include #include #include +#include +#include #include #include +#include #include #include @@ -42,6 +47,98 @@ constexpr size_t RECENT_CHAINLOCKS_TO_RETAIN{2}; BOOST_AUTO_TEST_SUITE(llmq_chainlock_tests) +BOOST_FIXTURE_TEST_CASE(historical_coinbase_lookup_from_disk, RegTestingSetup) +{ + const int activation = Params().GetConsensus().V20Height; + const auto signature = CreateRandomBLSSignature(); + std::deque hashes; + std::deque indexes; + CChain chain; + for (int height = 0; height <= activation + 128; ++height) { + CCbTx payload; + payload.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; + payload.nHeight = height; + if (height == activation + 20 || height == activation + 80 || height == activation + 110) { + int certified; + if (height == activation + 20) { + certified = activation + 1; + } else if (height == activation + 80) { + certified = activation + 65; + } else { + certified = activation + 99; + } + payload.bestCLSignature = signature; + payload.bestCLHeightDiff = height - certified - 1; + } + CMutableTransaction coinbase; + coinbase.nVersion = 3; + coinbase.nType = TRANSACTION_COINBASE; + coinbase.vin.resize(1); + coinbase.vin[0].scriptSig = CScript() << height << OP_0; + coinbase.vout.emplace_back(0, CScript() << OP_TRUE); + SetTxPayload(coinbase, payload); + CBlock block; + block.nVersion = 1; + block.hashPrevBlock = height ? hashes.back() : uint256{}; + block.nBits = Params().GenesisBlock().nBits; + block.nTime = Params().GenesisBlock().nTime + height; + block.vtx = {MakeTransactionRef(coinbase)}; + block.hashMerkleRoot = BlockMerkleRoot(block); + while (!CheckProofOfWork(block.GetHash(), block.nBits, Params().GetConsensus())) + ++block.nNonce; + hashes.push_back(block.GetHash()); + indexes.emplace_back(block); + auto& index = indexes.back(); + index.phashBlock = &hashes.back(); + index.nHeight = height; + index.pprev = height ? &indexes[height - 1] : nullptr; + index.BuildSkip(); + const auto pos = m_node.chainman->m_blockman.SaveBlockToDisk(block, height, nullptr); + BOOST_REQUIRE(!pos.IsNull()); + { + LOCK(cs_main); + index.nFile = pos.nFile; + index.nDataPos = pos.nPos; + index.nStatus = BLOCK_HAVE_DATA; + } + chain.SetTip(index); + } + chainlock::CoinbaseChainLockReader reader(chain); + BOOST_CHECK(!reader.Read(activation + 2)); + BOOST_CHECK(!reader.Find(-1, activation + 128)); + BOOST_CHECK(!reader.Find(activation + 128, activation + 128)); + for (int minimum = activation; minimum <= activation + 100; ++minimum) { + const int expected = minimum <= activation + 1 ? activation + 1 + : minimum <= activation + 65 ? activation + 65 + : activation + 99; + const auto entry = reader.Find(minimum, activation + 128); + if (minimum > activation + 99) { + BOOST_CHECK(!entry); + continue; + } + BOOST_REQUIRE(entry); + BOOST_CHECK_EQUAL(entry->clsig.getHeight(), expected); + BOOST_CHECK(entry->clsig.getBlockHash() == chain[expected]->GetBlockHash()); + BOOST_CHECK(entry->clsig.getSig() == signature); + const int carrier = expected == activation + 1 ? activation + 20 + : expected == activation + 65 ? activation + 80 + : activation + 110; + BOOST_CHECK_EQUAL(entry->carrier->nHeight, carrier); + BOOST_CHECK(!reader.Find(minimum, expected - 1)); + } + // A new request on a shorter chain must not reuse the old request's cache. + CChain shorter; + shorter.SetTip(*chain[activation + 66]); + chainlock::CoinbaseChainLockReader after_disconnect(shorter); + BOOST_CHECK(!after_disconnect.Find(activation + 65, activation + 65)); + BOOST_REQUIRE(after_disconnect.Find(activation + 1, activation + 1)); + + // Unavailable block data is an error, distinct from an absent certificate. + WITH_LOCK(cs_main, indexes[activation + 3].nStatus &= ~BLOCK_HAVE_DATA); + chainlock::CoinbaseChainLockReader unavailable(chain); + BOOST_CHECK_THROW(unavailable.Read(activation + 3), std::runtime_error); +} + BOOST_AUTO_TEST_CASE(chainlock_construction_test) { // Test default constructor From 02f1303da99cf291c8d7044933ccccf4e6b183f7 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Sep 2026 00:39:53 -0500 Subject: [PATCH 02/10] feat(llmq): construct and verify compact snapshot proofs Implement the DASHNC02 certificate proof layering from DIP #175 (https://github.com/dashpay/dips/pull/175). Independently pinned snapshot roots authenticate the initial quorum key. Mining-transaction inclusion proofs and short X11 header bridges authenticate subsequent quorum keys, whose ChainLock certificates authenticate the final state roots. The trust model assumes historically authenticated quorums remain honest; it does not reconstruct full Core consensus, DKG or exact active signer eligibility. Consecutive headers are required within each handoff, rather than across the entire elapsed history, to keep long-range proofs compact. The historical reader and commitment-selection machinery recover the signers and mining evidence needed to construct these handoffs without a new persistent index. Bounded caches reuse only immutable verification inputs or entries tied to the request's exact block history. --- src/Makefile.am | 3 + src/Makefile.test.include | 1 + src/llmq/quorumproofdata.h | 32 ++ src/llmq/quorumproofs.cpp | 556 ++++++++++++++++++++++++++++++ src/llmq/quorumproofs.h | 83 +++++ src/llmq/quorumsman.cpp | 186 +++++++++- src/llmq/quorumsman.h | 11 + src/test/quorum_proofs_tests.cpp | 239 +++++++++++++ test/util/data/non-backported.txt | 1 + 9 files changed, 1111 insertions(+), 1 deletion(-) create mode 100644 src/llmq/quorumproofdata.h create mode 100644 src/llmq/quorumproofs.cpp create mode 100644 src/llmq/quorumproofs.h create mode 100644 src/test/quorum_proofs_tests.cpp diff --git a/src/Makefile.am b/src/Makefile.am index c5b08a2e17f7..7b365f26f032 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -314,6 +314,8 @@ BITCOIN_CORE_H = \ llmq/observer.h \ llmq/options.h \ llmq/params.h \ + llmq/quorumproofdata.h \ + llmq/quorumproofs.h \ llmq/quorums.h \ llmq/quorumsman.h \ llmq/signhash.h \ @@ -598,6 +600,7 @@ libbitcoin_node_a_SOURCES = \ llmq/net_signing.cpp \ llmq/observer.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 63635c7daa66..9a6daa947fb4 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -151,6 +151,7 @@ BITCOIN_TESTS =\ test/llmq_snapshot_tests.cpp \ test/llmq_utils_tests.cpp \ test/logging_tests.cpp \ + test/quorum_proofs_tests.cpp \ test/masternode_payments_tests.cpp \ test/dbwrapper_tests.cpp \ test/validation_tests.cpp \ diff --git a/src/llmq/quorumproofdata.h b/src/llmq/quorumproofdata.h new file mode 100644 index 000000000000..36adc0975abf --- /dev/null +++ b/src/llmq/quorumproofdata.h @@ -0,0 +1,32 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying file COPYING. +#ifndef BITCOIN_LLMQ_QUORUMPROOFDATA_H +#define BITCOIN_LLMQ_QUORUMPROOFDATA_H +#include +#include +#include +#include + +namespace llmq { + +inline constexpr size_t MAX_PROOF_BYTES = 1024 * 1024; +inline constexpr size_t MAX_PROOF_CERTIFICATES = 4096; +inline constexpr size_t MAX_PROOF_HEADERS = 4096; +struct ProofMerklePath { + uint32_t index{0}; + uint32_t count{0}; + std::vector siblings; + SERIALIZE_METHODS(ProofMerklePath, obj) { READWRITE(obj.index, obj.count, obj.siblings); } + bool Verify(uint256 leaf, const uint256& root) const; + static ProofMerklePath Build(const std::vector& leaves, uint32_t index); +}; + +struct ProofTransaction { + std::vector transaction; + ProofMerklePath path; + SERIALIZE_METHODS(ProofTransaction, obj) { READWRITE(obj.transaction, obj.path); } + bool Verify(const CBlockHeader& header) const; + static ProofTransaction Build(const CBlock& block, uint32_t index); +}; +} // 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..bdc8bef7dee6 --- /dev/null +++ b/src/llmq/quorumproofs.cpp @@ -0,0 +1,556 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying file COPYING. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace llmq { +// Eight-byte, versioned wire identifier: DASH + Network Compact + format 02. +static constexpr std::array PROOF_MAGIC{'D', 'A', 'S', 'H', 'N', 'C', '0', '2'}; + +// Keys bind immutable verification inputs or an exact block history. Checkpoint +// membership, heights, ancestry and roots are still checked on every request. +// Explicit eviction thresholds bound memory without evicting on every insertion. +template +class ProofCache +{ + Mutex m_mutex; + Uint256LruHashMap m_entries GUARDED_BY(m_mutex); + +public: + bool Get(const uint256& key, T& value) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + LOCK(m_mutex); + return m_entries.get(key, value); + } + + void Insert(const uint256& key, const T& value) EXCLUSIVE_LOCKS_REQUIRED(!m_mutex) + { + LOCK(m_mutex); + m_entries.insert(key, value); + } +}; + +static void Require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +static void WriteBlob(CDataStream& out, const std::vector& bytes) +{ + Require(bytes.size() <= MAX_PROOF_BYTES, "proof blob limit"); + out << uint32_t(bytes.size()); + out.write(AsBytes(Span{bytes})); +} + +static std::vector ReadBlob(CDataStream& in, size_t limit) +{ + uint32_t size; + in >> size; + Require(size <= limit && size <= in.size(), "proof blob length"); + std::vector bytes(size); + in.read(AsWritableBytes(Span{bytes})); + return bytes; +} + +static void WritePath(CDataStream& out, const ProofMerklePath& path) +{ + Require(path.siblings.size() <= 17, "proof path limit"); + out << path.index << path.count << uint8_t(path.siblings.size()); + for (const auto& hash : path.siblings) out << hash; +} + +static ProofMerklePath ReadPath(CDataStream& in) +{ + ProofMerklePath path; + uint8_t size; + in >> path.index >> path.count >> size; + Require(size <= 17 && size_t(size) * 32 <= in.size(), "proof path length"); + path.siblings.resize(size); + for (auto& hash : path.siblings) in >> hash; + return path; +} + +static void WriteTransaction(CDataStream& out, const ProofTransaction& tx) +{ + Require(tx.transaction.size() <= 100000, "proof transaction limit"); + WriteBlob(out, tx.transaction); + WritePath(out, tx.path); +} + +static ProofTransaction ReadTransaction(CDataStream& in) +{ + ProofTransaction tx; + tx.transaction = ReadBlob(in, 100000); + tx.path = ReadPath(in); + return tx; +} + +static void WriteCertificate(CDataStream& out, const ProofCertificate& cert) +{ + out << cert.height << cert.header; + cert.signature.Serialize(out, false); +} + +static ProofCertificate ReadCertificate(CDataStream& in) +{ + ProofCertificate cert; + in >> cert.height >> cert.header; + cert.signature.Unserialize(in, false); + return cert; +} + +template static std::vector ConsensusBytes(const T& value) +{ + CDataStream out(SER_NETWORK, PROTOCOL_VERSION); + out << value; + return {UCharCast(out.data()), UCharCast(out.data()) + out.size()}; +} + +static CFinalCommitment ParseCommitment(const std::vector& bytes) +{ + Require(bytes.size() <= 1024, "commitment size"); + static ProofCache cache; + const auto key = Hash(bytes); + CFinalCommitment commitment; + if (cache.Get(key, commitment)) return commitment; + CDataStream in(bytes, SER_NETWORK, PROTOCOL_VERSION); + in >> commitment; + Require(in.empty() && (commitment.nVersion == 3 || commitment.nVersion == 4) && + !commitment.IsNull() && commitment.quorumPublicKey.IsValid() && + !commitment.quorumHash.IsNull(), "invalid Basic BLS commitment"); + const auto params = Params().GetLLMQ(commitment.llmqType); + Require(params.has_value(), "unsupported quorum type"); + Require((commitment.nVersion == 4) == (int(commitment.llmqType) == 5) && + commitment.quorumIndex >= 0 && commitment.quorumIndex < 32 && + commitment.signers.size() == size_t(params->size) && commitment.validMembers.size() == size_t(params->size) && + commitment.CountSigners() >= params->threshold && commitment.CountValidMembers() >= params->threshold && + ConsensusBytes(commitment) == bytes, "noncanonical or undersized commitment"); + cache.Insert(key, commitment); + return commitment; +} + +static CTransactionRef ParseTransaction(const std::vector& bytes) +{ + Require(bytes.size() != 64 && bytes.size() <= 100000, "transaction size"); + CDataStream in(bytes, SER_NETWORK, PROTOCOL_VERSION); + CMutableTransaction tx; + in >> tx; + Require(in.empty(), "trailing transaction data"); + return MakeTransactionRef(tx); +} + +static CCbTx Coinbase(const ProofTransaction& proof, const ProofCertificate& cert) +{ + Require(proof.path.index == 0 && proof.Verify(cert.header), "coinbase inclusion"); + const auto tx = ParseTransaction(proof.transaction); + Require(tx->IsCoinBase() && tx->nVersion == 3 && tx->nType == TRANSACTION_COINBASE && + !tx->vin[0].scriptSig.empty() && tx->vin[0].scriptSig.size() <= 100 && + !tx->vout.empty() && tx->vout.size() <= 4096, "coinbase envelope"); + auto payload = GetTxPayload(*tx); + Require(payload && payload->nVersion >= CCbTx::Version::CLSIG_AND_BALANCE && + payload->nHeight == int64_t(cert.height) && payload->bestCLHeightDiff < cert.height && + !payload->merkleRootQuorums.IsNull(), "coinbase payload"); + return *payload; +} + +bool ProofMerklePath::Verify(uint256 leaf, const uint256& root) const +{ + if (count == 0 || count > 100000 || index >= count || siblings.size() > 17) return false; + uint32_t width = count; + uint32_t position = index; + for (const auto& sibling : siblings) { + if (width <= 1) return false; + const bool duplicate = (position ^ 1U) >= width; + if (duplicate ? sibling != leaf : sibling == leaf) return false; + leaf = position & 1 ? Hash(sibling, leaf) : Hash(leaf, sibling); + position >>= 1; + width = width / 2 + width % 2; + } + return width == 1 && position == 0 && leaf == root; +} + +ProofMerklePath ProofMerklePath::Build(const std::vector& leaves, uint32_t index) +{ + Require(index < leaves.size() && leaves.size() <= std::numeric_limits::max(), "merkle index"); + ProofMerklePath path{index, uint32_t(leaves.size()), {}}; + auto layer = leaves; + while (layer.size() > 1) { + path.siblings.push_back(layer[std::min(size_t(index ^ 1U), layer.size() - 1)]); + if (layer.size() & 1) layer.push_back(layer.back()); + for (size_t i = 0; i < layer.size(); i += 2) layer[i / 2] = Hash(layer[i], layer[i + 1]); + layer.resize(layer.size() / 2); + index >>= 1; + } + Require(path.Verify(leaves[path.index], layer[0]), "mutated merkle tree"); + return path; +} + +bool ProofTransaction::Verify(const CBlockHeader& header) const +{ + return transaction.size() != 64 && transaction.size() <= 100000 && path.Verify(Hash(transaction), header.hashMerkleRoot); +} + +ProofTransaction ProofTransaction::Build(const CBlock& block, uint32_t index) +{ + Require(index < block.vtx.size(), "transaction index"); + std::vector hashes; + for (const auto& tx : block.vtx) hashes.push_back(tx->GetHash()); + ProofTransaction proof{ConsensusBytes(*block.vtx[index]), ProofMerklePath::Build(hashes, index)}; + Require(proof.Verify(block.GetBlockHeader()), "transaction root mismatch"); + return proof; +} + +bool ProofCertificate::Verify(const CFinalCommitment& signer, uint32_t minimum, Consensus::LLMQType kind) const +{ + if (height <= minimum || height > INT32_MAX || signer.llmqType != kind || !signature.IsValid()) return false; + SignHash hash{kind, signer.quorumHash, chainlock::GenSigRequestId(height), header.GetHash()}; + // Bind every input to Basic BLS verification, including the public key and + // signature themselves. A quorum hash or signed message alone is insufficient. + CHashWriter writer(SER_GETHASH, 0); + writer << hash.Get(); + signer.quorumPublicKey.Serialize(writer, false); + signature.Serialize(writer, false); + const auto key = writer.GetHash(); + static ProofCache cache; + bool valid{false}; + if (cache.Get(key, valid)) return valid; + if (!signature.VerifyInsecure(signer.quorumPublicKey, hash.Get(), false)) return false; + cache.Insert(key, true); + return true; +} + +std::vector QuorumProofChain::Encode() const +{ + Require(links.size() < MAX_PROOF_CERTIFICATES, "certificate limit"); + CDataStream out(SER_NETWORK, PROTOCOL_VERSION); + out.write(AsBytes(Span{PROOF_MAGIC})); + out << anchor; + WriteBlob(out, seed); + WritePath(out, seedPath); + out << uint16_t(links.size()); + size_t remaining = MAX_PROOF_HEADERS; + for (const auto& link : links) { + Require(link.ancestors.size() <= remaining, "total ancestor limit"); + remaining -= link.ancestors.size(); + WriteCertificate(out, link.certificate); + WriteTransaction(out, link.mining); + out << uint16_t(link.ancestors.size()); + for (const auto& header : link.ancestors) out << header; + Require(out.size() <= MAX_PROOF_BYTES, "proof size limit"); + } + WriteCertificate(out, target); + WriteTransaction(out, coinbase); + Require(out.size() <= MAX_PROOF_BYTES, "proof size limit"); + return {UCharCast(out.data()), UCharCast(out.data()) + out.size()}; +} + +QuorumProofChain QuorumProofChain::Decode(const std::vector& bytes) +{ + Require(bytes.size() <= MAX_PROOF_BYTES, "proof size limit"); + CDataStream in(bytes, SER_NETWORK, PROTOCOL_VERSION); + std::array magic; + in.read(AsWritableBytes(Span{magic})); + Require(magic == PROOF_MAGIC, "proof version"); + QuorumProofChain proof; + in >> proof.anchor; + proof.seed = ReadBlob(in, 1024); + proof.seedPath = ReadPath(in); + uint16_t count; + in >> count; + Require(count < MAX_PROOF_CERTIFICATES, "certificate limit"); + size_t remaining = MAX_PROOF_HEADERS; + for (size_t i = 0; i < count; ++i) { + ProofLink link; + link.certificate = ReadCertificate(in); + link.mining = ReadTransaction(in); + uint16_t headers; + in >> headers; + Require(headers <= remaining && size_t(headers) * 80 <= in.size(), "ancestor length/budget"); + remaining -= headers; + link.ancestors.resize(headers); + for (auto& header : link.ancestors) in >> header; + proof.links.push_back(std::move(link)); + } + proof.target = ReadCertificate(in); + proof.coinbase = ReadTransaction(in); + Require(in.empty(), "trailing proof data"); + return proof; +} + +ProofState QuorumProofChain::Verify(const ProofState& trusted) const +{ + Require(anchor == trusted && anchor.height >= (anchor.network == 0 ? 1987776U : 905100U) && + anchor.height <= INT32_MAX && !anchor.blockHash.IsNull() && !anchor.quorumRoot.IsNull(), + "untrusted snapshot"); + // The application fixes the network and initial roots; the relay cannot choose them. + Require(anchor.network <= 1, "unsupported proof network"); + const auto kind = anchor.network == 0 ? Consensus::LLMQType::LLMQ_400_60 : Consensus::LLMQType::LLMQ_50_60; + auto signer = ParseCommitment(seed); + Require(seedPath.Verify(Hash(seed), anchor.quorumRoot), "seed membership"); + Require(links.size() < MAX_PROOF_CERTIFICATES, "certificate limit"); + uint32_t height = anchor.height; + size_t remaining = MAX_PROOF_HEADERS; + for (const auto& link : links) { + Require(link.certificate.Verify(signer, height, kind), "ChainLock signature/height"); + Require(link.ancestors.size() <= remaining && link.ancestors.size() < link.certificate.height, "ancestor budget/height"); + remaining -= link.ancestors.size(); + const CBlockHeader* descendant = &link.certificate.header; + for (auto it = link.ancestors.rbegin(); it != link.ancestors.rend(); ++it) { + Require(descendant->hashPrevBlock == it->GetHash(), "ancestor continuity"); + descendant = &*it; + } + Require(link.mining.path.index != 0 && link.mining.Verify(*descendant), "mining inclusion"); + auto tx = ParseTransaction(link.mining.transaction); + Require(tx->nVersion == 3 && tx->nType == TRANSACTION_QUORUM_COMMITMENT && + tx->vin.empty() && tx->vout.empty() && tx->nLockTime == 0, "quorum transaction envelope"); + auto payload = GetTxPayload(*tx); + Require(payload && payload->nVersion == 1 && payload->nHeight == link.certificate.height - link.ancestors.size(), "quorum mining height"); + auto next = ParseCommitment(ConsensusBytes(payload->commitment)); + Require(next.quorumHash != signer.quorumHash, "redundant signer"); + signer = std::move(next); + height = link.certificate.height; + } + Require(target.Verify(signer, height, kind), "final ChainLock signature/height"); + auto cb = Coinbase(coinbase, target); + return {anchor.network, target.height, target.header.GetHash(), cb.merkleRootMNList, cb.merkleRootQuorums}; +} + +std::vector EncodeBootstrap(const QuorumProofChain& proof, const std::vector& records) +{ + Require(!records.empty() && records.size() <= 16, "projection count"); + const auto state = proof.Verify(proof.anchor); + CDataStream out(SER_NETWORK, PROTOCOL_VERSION); + WriteBlob(out, proof.Encode()); + out << uint8_t(records.size()); + for (const auto& record : records) { + Require(record.kind <= 1 && !record.leaf.empty() && record.leaf.size() != 64 && record.leaf.size() <= 4096 && + record.path.Verify(Hash(record.leaf), record.kind == 0 ? state.quorumRoot : state.masternodeRoot), "projection inclusion"); + out << record.kind; + WriteBlob(out, record.leaf); + WritePath(out, record.path); + } + Require(out.size() <= MAX_PROOF_BYTES, "bootstrap size limit"); + return {UCharCast(out.data()), UCharCast(out.data()) + out.size()}; +} + +UniValue ProofState::ToJson() const +{ + UniValue value(UniValue::VOBJ); + value.pushKV("network", network); + value.pushKV("height", height); + value.pushKV("block_hash", blockHash.ToString()); + value.pushKV("masternode_root", masternodeRoot.ToString()); + value.pushKV("quorum_root", quorumRoot.ToString()); + return value; +} + +ProofState ProofState::FromJson(const UniValue& value) +{ + Require(value.isObject() && value.exists("network") && value.exists("height") && + value.exists("block_hash") && value.exists("masternode_root") && value.exists("quorum_root"), + "snapshot fields missing"); + ProofState state; + state.network = value["network"].getInt(); + state.height = value["height"].getInt(); + auto parse = [&](const char* name) { + const auto text = value[name].get_str(); + Require(text.size() == 64 && IsHex(text), "snapshot hash encoding"); + return uint256S(text); + }; + state.blockHash = parse("block_hash"); + state.masternodeRoot = parse("masternode_root"); + state.quorumRoot = parse("quorum_root"); + return state; +} + +std::vector QuorumProofBuilder::ActiveCommitments(const CBlockIndex* index) const +{ + Require(index != nullptr, "missing block index"); + std::vector result; + { + LOCK(cs_main); + for (const auto& [type, indexes] : m_quorum_block_processor.GetMinedAndActiveCommitmentsUntilBlock(index)) { + for (const auto* base : indexes) { + auto [commitment, mined] = m_quorum_block_processor.GetMinedCommitment(type, base->GetBlockHash()); + Require(!commitment.IsNull() && !mined.IsNull(), "missing active commitment"); + result.push_back(std::move(commitment)); + } + } + } + std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) { return SerializeHash(a) < SerializeHash(b); }); + std::vector hashes; + for (const auto& commitment : result) hashes.push_back(SerializeHash(commitment)); + Require(ComputeMerkleRoot(hashes) == StateAt(index).quorumRoot, "active quorum root mismatch"); + return result; +} + +ProofState QuorumProofBuilder::StateAt(const CBlockIndex* index) +{ + Require(index != nullptr && index->nHeight > 0, "invalid checkpoint block"); + const auto network = Params().NetworkIDString(); + Require(network == "main" || network == "test", "proofs support mainnet/testnet"); + CBlock block; + Require(node::ReadBlockFromDisk(block, index, Params().GetConsensus()) && !block.vtx.empty(), "historical block unavailable"); + ProofCertificate cert{uint32_t(index->nHeight), block.GetBlockHeader(), {}}; + auto cb = Coinbase(ProofTransaction::Build(block, 0), cert); + return {uint8_t(network == "main" ? 0 : 1), uint32_t(index->nHeight), index->GetBlockHash(), cb.merkleRootMNList, cb.merkleRootQuorums}; +} + +std::optional QuorumProofBuilder::DetermineChainlockSigningCommitment(int32_t height) const +{ + const auto params = Params().GetLLMQ(Params().GetConsensus().llmqTypeChainLocks); + if (!params || height < SIGN_HEIGHT_OFFSET) return std::nullopt; + const auto* start = m_chain[height - SIGN_HEIGHT_OFFSET]; + if (!start) return std::nullopt; + // This hash commits to the entire selection history, including its branch. + // The type and request height fix the remaining signing-selection inputs. + const auto key = (CHashWriter(SER_GETHASH, 0) << start->GetBlockHash() << params->type << height).GetHash(); + static ProofCache cache; + CFinalCommitment cached; + if (cache.Get(key, cached)) return cached; + LOCK(cs_main); + // The commitment database follows the active chain. Do not populate a + // snapshot's cache from another branch if a reorg raced this request. + Require(m_chainman.ActiveChain().Contains(start), "proof chain changed during construction"); + auto result = SelectCommitmentForSigning(*params, m_chain, m_qman, chainlock::GenSigRequestId(height), height, + SIGN_HEIGHT_OFFSET); + if (result) cache.Insert(key, *result); + return result; +} + +static const CBlockIndex* MinedCommitmentBlock(const CQuorumBlockProcessor& processor, const CChain& chain, + const node::BlockManager& blocks, Consensus::LLMQType kind, + const uint256& quorum) +{ + const auto key = (CHashWriter(SER_GETHASH, 0) << kind << quorum).GetHash(); + static ProofCache cache; + uint256 mined_hash; + LOCK(cs_main); + // A quorum can be mined differently after a reorg. Reuse an entry only if + // its exact mining block is an ancestor in this request's chain snapshot. + if (cache.Get(key, mined_hash)) { + const auto* mined = blocks.LookupBlockIndex(mined_hash); + if (mined && chain.Contains(mined)) return mined; + } + const auto result = processor.GetMinedCommitment(kind, quorum); + const auto* mined = blocks.LookupBlockIndex(result.second); + if (result.first.IsNull() || !mined || !chain.Contains(mined)) return nullptr; + cache.Insert(key, result.second); + return mined; +} + +QuorumProofChain QuorumProofBuilder::Build(const CBlockIndex* checkpoint, const chainlock::ChainLockSig& target) const +{ + const auto& chain = m_chain; + const auto& blocks = m_chainman.m_blockman; + const auto* targetIndex = chain[target.getHeight()]; + Require(checkpoint && targetIndex && chain.Contains(checkpoint) && checkpoint->nHeight < targetIndex->nHeight, + "checkpoint/target not on chain"); + Require(targetIndex->GetBlockHash() == target.getBlockHash(), "target ChainLock block mismatch"); + auto certificate = [&](const chainlock::CoinbaseChainLock& entry) { + return ProofCertificate{uint32_t(entry.clsig.getHeight()), chain[entry.clsig.getHeight()]->GetBlockHeader(), + entry.clsig.getSig()}; + }; + QuorumProofChain proof; + proof.anchor = StateAt(checkpoint); + auto seeds = ActiveCommitments(checkpoint); + std::set seedHashes; + std::vector seedLeaves; + const auto kind = Params().GetConsensus().llmqTypeChainLocks; + for (const auto& seed : seeds) { + seedLeaves.push_back(SerializeHash(seed)); + if (seed.llmqType == kind && seed.nVersion >= 3) seedHashes.insert(seed.quorumHash); + } + proof.target = {uint32_t(target.getHeight()), targetIndex->GetBlockHeader(), target.getSig()}; + auto needed = DetermineChainlockSigningCommitment(targetIndex->nHeight); + Require(needed.has_value(), "target signer unavailable"); + int32_t later = targetIndex->nHeight; + size_t remaining = MAX_PROOF_HEADERS; + while (!seedHashes.count(needed->quorumHash)) { + Require(proof.links.size() < MAX_PROOF_CERTIFICATES - 1, "certificate budget exhausted"); + Require(!ShutdownRequested(), "proof construction interrupted"); + const auto* mined = MinedCommitmentBlock(m_quorum_block_processor, chain, blocks, kind, needed->quorumHash); + Require(mined && mined->nHeight > checkpoint->nHeight, "no bridge to snapshot"); + CBlock block; + Require(node::ReadBlockFromDisk(block, mined, Params().GetConsensus()), "mining block unavailable"); + std::optional mining; + for (size_t i = 1; i < block.vtx.size(); ++i) { + if (block.vtx[i]->nType != TRANSACTION_QUORUM_COMMITMENT) continue; + const auto payload = GetTxPayload(*block.vtx[i]); + if (payload && !payload->commitment.IsNull() && payload->commitment.llmqType == kind && + payload->commitment.quorumHash == needed->quorumHash) { + mining = ProofTransaction::Build(block, i); + break; + } + } + Require(mining.has_value(), "mining transaction unavailable"); + std::optional best; + std::optional predecessor; + double bestScore = std::numeric_limits::infinity(); + // Exact bytes/progress over nearby candidates. Expand the search only + // when the near window cannot bridge; the verifier does not depend on this heuristic. + const int32_t maximum = std::min(later - 1, mined->nHeight + int32_t(remaining)); + for (int32_t height = mined->nHeight; height <= maximum; ++height) { + if (height > mined->nHeight + 8 && best) break; + const auto entry = m_chainlocks.Find(height, maximum); + if (!entry) break; + height = entry->clsig.getHeight(); + if (height > mined->nHeight + 8 && best) break; + auto signer = DetermineChainlockSigningCommitment(height); + if (!signer || signer->quorumHash == needed->quorumHash) continue; + const auto* previous = MinedCommitmentBlock(m_quorum_block_processor, chain, blocks, kind, signer->quorumHash); + if (!previous || previous->nHeight >= mined->nHeight || (previous->nHeight <= checkpoint->nHeight && !seedHashes.count(signer->quorumHash))) continue; + auto cert = certificate(*entry); + if (!cert.Verify(*signer, checkpoint->nHeight, kind)) continue; + const auto cost = 180 + 4 + mining->transaction.size() + 9 + 32 * mining->path.siblings.size() + 2 + + 80 * (height - mined->nHeight); + const double score = double(cost) / (mined->nHeight - std::max(checkpoint->nHeight, previous->nHeight)); + if (score >= bestScore) continue; + bestScore = score; + best = ProofLink{cert, *mining, {}}; + predecessor = std::move(signer); + } + Require(best && predecessor, "no B-only route within proof budget"); + for (int32_t h = mined->nHeight; h < int64_t(best->certificate.height); ++h) best->ancestors.push_back(chain[h]->GetBlockHeader()); + remaining -= best->ancestors.size(); + later = best->certificate.height; + needed = std::move(predecessor); + proof.links.push_back(std::move(*best)); + } + std::reverse(proof.links.begin(), proof.links.end()); + for (size_t i = 0; i < seeds.size(); ++i) { + if (seeds[i].llmqType == kind && seeds[i].quorumHash == needed->quorumHash) { + proof.seed = ConsensusBytes(seeds[i]); + proof.seedPath = ProofMerklePath::Build(seedLeaves, i); + break; + } + } + CBlock finalBlock; + Require(node::ReadBlockFromDisk(finalBlock, targetIndex, Params().GetConsensus()), "final block unavailable"); + proof.coinbase = ProofTransaction::Build(finalBlock, 0); + Require(proof.Verify(proof.anchor) == StateAt(targetIndex), "constructed proof does not match chain"); + proof.Encode(); + return proof; +} + +} // namespace llmq diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h new file mode 100644 index 000000000000..eeaa974e69f5 --- /dev/null +++ b/src/llmq/quorumproofs.h @@ -0,0 +1,83 @@ +// Copyright (c) 2025-2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying file COPYING. +#ifndef BITCOIN_LLMQ_QUORUMPROOFS_H +#define BITCOIN_LLMQ_QUORUMPROOFS_H +#include +#include +#include +#include +class CBlockIndex; +class CChain; +class CDataStream; +class ChainstateManager; +namespace chainlock { +class CoinbaseChainLockReader; +struct ChainLockSig; +} // namespace chainlock +namespace llmq { +class CQuorumBlockProcessor; +class CQuorumManager; +struct ProofState { + uint8_t network{0}; + uint32_t height{0}; + uint256 blockHash; + uint256 masternodeRoot; + uint256 quorumRoot; + SERIALIZE_METHODS(ProofState, obj) { READWRITE(obj.network, obj.height, obj.blockHash, obj.masternodeRoot, obj.quorumRoot); } + bool operator==(const ProofState&) const = default; + UniValue ToJson() const; + static ProofState FromJson(const UniValue& value); +}; +struct ProofCertificate { + uint32_t height{0}; + CBlockHeader header; + CBLSSignature signature; + bool Verify(const CFinalCommitment& signer, uint32_t minimum, Consensus::LLMQType kind) const; +}; +struct ProofLink { + ProofCertificate certificate; + ProofTransaction mining; + std::vector ancestors; +}; +struct QuorumProofChain { + ProofState anchor; + std::vector seed; + ProofMerklePath seedPath; + std::vector links; + ProofCertificate target; + ProofTransaction coinbase; + std::vector Encode() const; + static QuorumProofChain Decode(const std::vector& bytes); + ProofState Verify(const ProofState& trusted) const; +}; +struct ProofProjection { + uint8_t kind{0}; + std::vector leaf; + ProofMerklePath path; +}; +std::vector EncodeBootstrap(const QuorumProofChain& proof, const std::vector& records); +class QuorumProofBuilder +{ + const CQuorumBlockProcessor& m_quorum_block_processor; + const CQuorumManager& m_qman; + const CChain& m_chain; + const ChainstateManager& m_chainman; + chainlock::CoinbaseChainLockReader& m_chainlocks; + std::optional DetermineChainlockSigningCommitment(int32_t height) const; + +public: + QuorumProofBuilder(const CQuorumBlockProcessor& processor, const CQuorumManager& qman, const CChain& chain, + const ChainstateManager& chainman, chainlock::CoinbaseChainLockReader& chainlocks) : + m_quorum_block_processor(processor), + m_qman(qman), + m_chain(chain), + m_chainman(chainman), + m_chainlocks(chainlocks) + { + } + std::vector ActiveCommitments(const CBlockIndex* index) const; + static ProofState StateAt(const CBlockIndex* index); + QuorumProofChain Build(const CBlockIndex* checkpoint, const chainlock::ChainLockSig& target) const; +}; +} // namespace llmq +#endif // BITCOIN_LLMQ_QUORUMPROOFS_H diff --git a/src/llmq/quorumsman.cpp b/src/llmq/quorumsman.cpp index 7ba6eb2479f7..4fc074f4a8d6 100644 --- a/src/llmq/quorumsman.cpp +++ b/src/llmq/quorumsman.cpp @@ -284,6 +284,129 @@ 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; + std::optional continuationQuorumHash; + + { + LOCK(m_cs_maps); + 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(); + // Resolve the continuation point after releasing m_cs_maps. Both the + // mined-commitment lookup and block-index lookup take locks whose + // order is incompatible with m_cs_maps. + continuationQuorumHash = vecResultCommitments.back().quorumHash; + } + } 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)); + } + } + + if (continuationQuorumHash) { + uint256 continuationBlockHash; + if (llmq_params_opt->useRotation) { + // Rotating scans are keyed by quorum base blocks. Resuming from the + // mined block would revisit the other commitments from the same cycle. + continuationBlockHash = *continuationQuorumHash; + } else { + continuationBlockHash = quorumBlockProcessor.GetMinedCommitment(llmqType, *continuationQuorumHash).second; + } + const CBlockIndex* pLastIndex = WITH_LOCK(::cs_main, return m_chainman.m_blockman.LookupBlockIndex(continuationBlockHash)); + if (!pLastIndex || pLastIndex->pprev == nullptr) return {}; + pIndexScanCommitments = pLastIndex->pprev; + } + + // 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__, std23::to_underlying(llmqType), pQuorumBaseBlockIndex->GetBlockHash().ToString()); + return {}; + } + vecResultCommitments.emplace_back(std::move(qc)); + } + + const size_t nCountResult{vecResultCommitments.size()}; + if (nCountResult > 0) { + LOCK(m_cs_maps); + // 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) { @@ -673,6 +796,68 @@ VerifyRecSigStatus VerifyRecoveredSig(Consensus::LLMQType llmqType, const CQuoru 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 : util::irange(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]; + } +} + VerifyRecSigStatus VerifyRecoveredSig(Consensus::LLMQType llmqType, const CChain& active_chain, const CQuorumManager& qman, int signedAtHeight, const uint256& id, const uint256& msgHash, const CBLSSignature& sig, const int signOffset) @@ -680,5 +865,4 @@ VerifyRecSigStatus VerifyRecoveredSig(Consensus::LLMQType llmqType, const CChain const CBlockIndex* pindexStart = WITH_LOCK(::cs_main, return SelectQuorumForSigningStartBlock(active_chain, signedAtHeight, signOffset)); return VerifyRecoveredSig(llmqType, qman, pindexStart, id, msgHash, sig); } - } // namespace llmq diff --git a/src/llmq/quorumsman.h b/src/llmq/quorumsman.h index ac17caf45893..62201a7977a8 100644 --- a/src/llmq/quorumsman.h +++ b/src/llmq/quorumsman.h @@ -85,6 +85,8 @@ class CQuorumManager final mutable size_t m_inbound_request_count GUARDED_BY(cs_data_requests){0}; mutable Mutex m_cs_maps; + mutable std::map>> scanCommitmentsCache + GUARDED_BY(m_cs_maps); mutable PerLlmqTypeCache mapQuorumsCache GUARDED_BY(m_cs_maps); mutable PerLlmqTypeCache> scanQuorumsCache GUARDED_BY(m_cs_maps); @@ -145,6 +147,12 @@ class CQuorumManager final size_t nCountRequested, const CChain& chain) const EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_db, !m_cs_maps); + std::vector ScanCommitments(Consensus::LLMQType llmqType, size_t nCountRequested) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !m_cs_maps); + std::vector ScanCommitments(Consensus::LLMQType llmqType, gsl::not_null pindexStart, + size_t nCountRequested) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !m_cs_maps); + bool IsMasternode() const; bool IsWatching() const; @@ -211,6 +219,9 @@ CQuorumCPtr SelectQuorumForSigning(const Consensus::LLMQParams& llmq_params, con 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); + VerifyRecSigStatus VerifyRecoveredSig(Consensus::LLMQType llmqType, const CQuorumManager& qman, const CBlockIndex* pindexStart, const uint256& id, const uint256& msgHash, const CBLSSignature& sig); diff --git a/src/test/quorum_proofs_tests.cpp b/src/test/quorum_proofs_tests.cpp new file mode 100644 index 000000000000..aac2b0a3321b --- /dev/null +++ b/src/test/quorum_proofs_tests.cpp @@ -0,0 +1,239 @@ +// Copyright (c) 2025-2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying file COPYING. +#include +#include +#include +#include +#include +#include + +// Actual testnet archive proof, heights 1548500 -> 1549547. Independently +// verified by the Rust BLS/X11 implementation; includes 0, 1 and 4 ancestors. +static const char* TESTNET_PROOF = + "444153484e43303201d4a01700b7559b529c5645d6f17ec90261a98496f9f10173f2a1bc1646ac2d9ca90000002180d31e9d" + "cc7a0b62c266dac8d0019927b34583e07ccc9b6dc1913d8c9cf4c01cab99fd4356436b4dd3499bfa1211de45379fa01489b3" + "13c6ad20e11b904bba43010000030001f377b58af19b177c75bfc1c2c7ceb962911a2b68100db7bc4f50f988a700000032ff" + "ffffffffff0332ffffffffffff03b39c46eec719125f7d94b727669dada09c11f982eed537b3ebf2695745428e883f87787d" + "096f3dd339d6897313b4517197f27eb5c29258b07e0f41b44c9cdc7180f241c0b20fb749cd029cfef29e0230a850f28c4261" + "1d27c6987a461a38b348360a7f40f8ecbedb6c4f3114ed3ee0a074d20d8eac81a126e25bc17d65b2b01f010e97fdcd5a64dc" + "f72ac039da682641cd4f73ab7c77962ef770e146fe55d632192a8dcebbead81d4b6f0a71b8e010118c01d8a9e1c9ba466615" + "e6a52fb8508d30a687808a13d05a3019588fca4d5d76a3b54e7776e3b0c7b8fc010375a147d017a6b995e66c6ec77f144bdc" + "8bdffd41e924bc0071fd940c0a89083ee1841481ada9e0a925f6db0f0103fc277da18885020000006d000000070bbad189bc" + "db405fc4f337d3155f9a9254788edc95b3b8e85cdde5408655132bd95fff59b78300333e920f14c85318e3a08f151390c9db" + "07ea90e95a3dcd4a17335881e7ef79427373278c5d75db84b88db1b932a98280f0f63816c49bba7680356cc8d193deaafc50" + "abb4ed43c15cea4d4e4617884b013897020d2fa7cce9370fbf179099dfa4ff693ca19a4b5969ead9a032a89de35b2cdf296b" + "21330b6760a8660185d2dcf378464c4f1a41f9a23e8b60a4c2d81c942ec50bb6641b394dd878f4e1d26fbee716ee68ded746" + "6648d77b2b027cf2a68f14ae1df48686c4f1440300e3a0170000000020ffe8a875515f906d4e01c3afd3eb4539ac9bb72248" + "6636acce9bec0f51000000c66f1236ae22de4015406a059626031296d5ea5258a871ff8e32afefd6ca4af52b519d6a6fa800" + "1ef4b30600ac4d9c40b3dc3f3ef3ce00ca9d3383cd31da10271b3c77f6ca13fae18eb52174bb3f4f9e0dc501c0f53d9b0c7d" + "99aa870101e2c9898facc101097207d4882f7fb763255f9b73c306c0ce39aa6324449f5e53203fd6966f1afc3214a3a07977" + "8b5601000003000600000000000000fd49010100e3a017000300013febcaedd1db22c960be4055a715f953a636c3ad24497c" + "adf23b416e1c00000032ffffffffffff0332ffffffffffff038d2b0a51b13fd14ba80f1a0322807e65310c28c35c5a97d812" + "abaec677961e9ff918e742e977b7a9196d11437c60d1bf06e11160c25112684c0713d7d9347f2568cf000a5c3414e16ca415" + "16243d59b495434dc61c1c86e60a1277976ba427135a05a3d45ee68dd2cd9de705c843866c1c4537907c72570a976b031849" + "06f6a71667926727aaae73006d3334d718604782eb3fa5b162bf1c6f4319083ecb7bf5a1940315f41a8b16de92e372771466" + "1c82523b37f119ccfd2cb08268f3b41d80208116309117f9c7ea3c3c63a24ebe7c02b89a5aad8fea24025805232197889f02" + "b585abcef3949129f4ec9d0df597ebfcb0fe52a4269cc143a2fb2b60ded5b556ea3307d9edf0ce3cd2d3cb90cb472c010000" + "00040000000279ca353e0de915e395676997024b602d3856e940a413ed9c6f1d0c5defdd050fa59444f0db90dcfb4abebb6b" + "2181bc36afc13b8c17a0066810edbc248f9e3ada00001ca217000000002049519f8cc02a899b62f3d9d582b379afa66ca833" + "89aef217545be47e3b000000261154fda3fba0a9490d6772eeeb5ce019d8b1be03f085e8c2be88f71e1a2c3b06f99d6ae4cd" + "001e20460e00ab179f752254ba2100f5059eaaaff27aa58f5a3b11b7aeb2c24a7e1c1693d0a3f2ba330adf3df620d29fe3d7" + "afeb9de80b834c05f6a4be6ce1f0a9962972553c50265023a50cf6db2afb40d1f936bf957eeeeafaa74a34a7fcbb451c1bc5" + "4a7c5601000003000600000000000000fd490101001ba2170003000138e92399565737d10b9a47ce26d3742f1ee77b66d589" + "4530eb00d37cdb00000032ffffffffffff0332ffffffffffff03ae0caf5a868060c4efc24485f0f52a1552d1ed8b380fede4" + "40e5886242ba82dcb79c5edcb31a09e08eade03c6caeb60d0026d7812596a8806df233b87070dc759ca432a0d5ac8b397bdf" + "5c81a55dc954a42d9575d5b39d099b7a15fbad36db6cde0ff0bf58a9949530a9403f49150c323e528c0a410e02c076884885" + "ebcc846e18995c6ca7152824988505542ff6cbc45d32c09fb5c4e2c54acbf7d3d2c3903d641ad75bb0b5e683f33f89fdeb01" + "33469155424ac7247828917f2670edd70484c8447c7917b143125a01486e304de89218fe5b9780acb733d6d9c8127b688ae6" + "0233a382bab13dc36e26f8af7f6c80b1dc3fed2a4ea5e0e83192ce69a7d6d199e1104255174f4cbbb8ad8a54283e87490100" + "0000040000000209916264ab2466c1d9ff4863293b5872582ed9d91c3457db8661cd4536da68c066fecf371fcf1c13a7d810" + "e200172086a9a469e2a5e3095a40b322964771085c01000000002015d04f67aef05968f56c42835c3ee9ba14ff7e1845bd79" + "ffa98d5abb2a000000d4c6a527e26aa5ff70cbacbdb13feaa62a7dff1713b2eb0d81283eaa160c0ca59ff79d6a3bc9001ecf" + "c70c0047a41700000000202817b51778f715cd893af91961d4ca111d799efed31d386f5b6595f504000000962e5f6fae97a0" + "6781f0a95278511094c1f2fa4df863c426e73cd20b6ee373300e299f6ad938011ec05d0e0091cfab11aa40b0d23f47162d72" + "ce0f074279de31c3ce266c0d06b5aa2699e1b8880cf8308c8634cec380132541ecfe2b1878d43fcde1bd987b93bc2e59fd12" + "1526f92bb0df05d114ee4d1122d0d435668c84bd5972165b3dbd121f70254d101b5601000003000600000000000000fd4901" + "010043a41700030001d21f6a8ab554a886922406a9731081e7da5ded14f4ac2f9eb1ec3edd7700000032ffffffffffff0332" + "ffffffffffff03818bd5c7cc6914422d19f6cacf90f85fccc591a38d392378d07bfd25fc48437c59998b657253a0c076e603" + "7a564da0faff27d1174b74f2c5b68e4fdae4dd193c8bdee18d218e3ce061d3ac75726dbf0eb9395e2e1e07dd4757bde426b8" + "f6a9254d4f50d1c48eb51c4ff26415c294bf6e2196db7da343a0a85221ae04025fb726127349b23f6e8c9c9cc8c7ccbf81b2" + "4e1c69b322eddc8bfa3c17f2cc7c7a983568e5ba9e8544e93456a70ee2ddd758feaf51515b5c05084a9e68b167f57d84dc43" + "ed963ede7e34d879b4205a193b30d35fdceade18b606a1aa9c19e03f9048f00abc96d88309a9f06d427699caee299ba672b9" + "ecbbba19e72f61171cbe95451a9882240976c652390830c39a03e408f8010000000500000003e981e0af4ced7c8d0ca3e50e" + "9c438f8ab44dbdbd0b3f6145e95a55cdd262bbd8c2f12457d5fcdda836c8e8b872d3d35f3872551e557c6385016e40b963e5" + "6cebfa5b9e96f4bc7976d1a57b911054844ae039a821ece72a881a7ae0dc1715d2380400000000203c143d3b0c2a6025fd1f" + "c1e86536d54b6caddbbd5ccca5530d0e326f9001000068f398ba844230743ba54b449e7a18e7ad2cc69f831356a2bdf3f014" + "7afb2eef5a269f6aff5b011e2a7e010000000020405d948621803e2dea6e2674ac9ece02dc406d3ae6c57223c8d880581900" + "00001738c1b5f920940752bfa7dd408b1e05a2cc413513223eccf70d2ec8d03fbbce24279f6af816011ebcdb0c0000000020" + "7f02cb0da8cd8a368924e46eaca70cac0e35d53f37f8a6905955567c930000006e145b97802c84d157023105d8a8f847e295" + "2b29c8656f7f1343e9e186f0d51168289f6adb21011eed440a0000000020bcc820660fc954d5b5b163a934be5c2f5047948e" + "92279a46874d816522000000dfda21c0255e83d9df7f6d31ef5521fa150f6f40caca08d095ff489a14307606c4289f6a1f31" + "011e071c0900eba4170000000020ca2ba845159ffc0acbfa5b60b8cc2109f8987e66622f489458ed88a26c000000a09092e0" + "06bd3582faaba706775243c0c468f79cad4490e1cf537cf641cdff3db97e9f6a18a4001e78dd0e00a343ee60c14943a14984" + "8f7886a138d0616880abfeefc27fa0e0da9c1cae8835d5fad377095d298164cfffe9f5cdd6ec0a37d61a32aa9bab719faa15" + "881aa13ede275cf1a8eefd6789bffe1df6ec4759e1035f0dab3e2305016aa9e874f5ef063201000003000500010000000000" + "000000000000000000000000000000000000000000000000000000ffffffff016affffffff03b84b8c03000000001976a914" + "b489115851ca07a26a5ad8bac3cec3c7dbebd83188ac2ed5fd0300000000016af80da706000000001976a91464f2b2b84f62" + "d68a2cd7f7f5fb2b5aa75ef716d788ac00000000af0300eba417002180d31e9dcc7a0b62c266dac8d0019927b34583e07ccc" + "9b6dc1913d8c9cf4c04f7a628c9c683a9b5d0a865977ff6d0782e2ffc879981ec6e4fb7caa9d76496300b59e80b1894fe733" + "fad1e8316070314fe024b93d9b7b51baf63b2642d3d89495ee4bc69c68ec225e7eaa191719846fd00c0d5421047df2b42eac" + "1386d908c3e755f642d3055c5c3a75b85500b358dce7cc5a17730ad9c75ced9c9e98c491e98a9465d733de20000000000000" + "0400000002b3336e59b1bc4bdccc3397c24a85c882184d2fb8d6f0a563d19100cadc53bdeb4d864dd73362cbcda1ec96a3f3" + "3ad9678edcd1ff85bb3f2581b8d54d57685615" +; +BOOST_FIXTURE_TEST_SUITE(quorum_proofs_tests, BasicTestingSetup) +BOOST_AUTO_TEST_CASE(real_testnet_wire_and_crypto) { + auto bytes = ParseHex(TESTNET_PROOF); + auto proof = llmq::QuorumProofChain::Decode(bytes); + BOOST_CHECK(proof.Encode() == bytes); + auto target = proof.Verify(proof.anchor); + BOOST_CHECK_EQUAL(target.height, 1549547U); + BOOST_CHECK(target.blockHash == proof.target.header.GetHash()); + auto trusted = proof.anchor; + trusted.blockHash = uint256::ONE; + BOOST_CHECK_THROW(proof.Verify(trusted), std::exception); +} +BOOST_AUTO_TEST_CASE(all_truncations_and_old_format_rejected) { + auto bytes = ParseHex(TESTNET_PROOF); + for (size_t size = 0; size < bytes.size(); ++size) { + std::vector truncated(bytes.begin(), bytes.begin() + size); + BOOST_CHECK_THROW(llmq::QuorumProofChain::Decode(truncated), std::exception); + } + auto old = bytes; + old[7] = '1'; + BOOST_CHECK_THROW(llmq::QuorumProofChain::Decode(old), std::exception); + bytes.push_back(0); + BOOST_CHECK_THROW(llmq::QuorumProofChain::Decode(bytes), std::exception); +} +BOOST_AUTO_TEST_CASE(tampering_cannot_authenticate_state) { + const auto bytes = ParseHex(TESTNET_PROOF); + const auto trusted = llmq::QuorumProofChain::Decode(bytes).anchor; + for (size_t pos : {size_t(8), size_t(13), size_t(120), size_t(600), bytes.size() / 2, bytes.size() - 1}) { + auto bad = bytes; + bad[pos] ^= 1; + BOOST_CHECK_THROW(llmq::QuorumProofChain::Decode(bad).Verify(trusted), std::exception); + } + auto proof = llmq::QuorumProofChain::Decode(bytes); + proof.target.signature.Reset(); + BOOST_CHECK_THROW(proof.Verify(trusted), std::exception); + proof = llmq::QuorumProofChain::Decode(bytes); + proof.links[1].ancestors.clear(); + BOOST_CHECK_THROW(proof.Verify(trusted), std::exception); + proof = llmq::QuorumProofChain::Decode(bytes); + proof.links[2].ancestors[0].nNonce ^= 1; + BOOST_CHECK_THROW(proof.Verify(trusted), std::exception); +} +BOOST_AUTO_TEST_CASE(warm_certificate_cache_binds_all_verification_inputs) +{ + const auto proof = llmq::QuorumProofChain::Decode(ParseHex(TESTNET_PROOF)); + CDataStream stream(proof.seed, SER_NETWORK, PROTOCOL_VERSION); + llmq::CFinalCommitment signer; + stream >> signer; + const auto cert = proof.links.front().certificate; + const auto kind = Consensus::LLMQType::LLMQ_50_60; + BOOST_REQUIRE(cert.Verify(signer, proof.anchor.height, kind)); + BOOST_CHECK(cert.Verify(signer, proof.anchor.height + 1, kind)); + BOOST_CHECK(!cert.Verify(signer, cert.height, kind)); + BOOST_CHECK(!cert.Verify(signer, cert.height + 1, kind)); + BOOST_CHECK(!cert.Verify(signer, 0, Consensus::LLMQType::LLMQ_400_60)); + + auto changed = cert; + changed.height++; + BOOST_CHECK(!changed.Verify(signer, 0, kind)); + changed = cert; + changed.header.nNonce ^= 1; + BOOST_CHECK(!changed.Verify(signer, 0, kind)); + changed = cert; + changed.signature = proof.target.signature; // Valid BLS point, wrong signature. + BOOST_CHECK(!changed.Verify(signer, 0, kind)); + changed.signature.Reset(); + BOOST_CHECK(!changed.Verify(signer, 0, kind)); + + auto wrong_signer = signer; + wrong_signer.quorumHash = uint256::ONE; + BOOST_CHECK(!cert.Verify(wrong_signer, 0, kind)); + CBLSSecretKey secret; + secret.MakeNewKey(); + wrong_signer = signer; + wrong_signer.quorumPublicKey = secret.GetPublicKey(); + BOOST_CHECK(!cert.Verify(wrong_signer, 0, kind)); + wrong_signer.quorumPublicKey.Reset(); + BOOST_CHECK(!cert.Verify(wrong_signer, 0, kind)); + wrong_signer = signer; + wrong_signer.llmqType = Consensus::LLMQType::LLMQ_400_60; + BOOST_CHECK(!cert.Verify(wrong_signer, 0, wrong_signer.llmqType)); + BOOST_CHECK(cert.Verify(signer, proof.anchor.height, kind)); +} +BOOST_AUTO_TEST_CASE(warm_proof_cache_preserves_context_checks) +{ + const auto proof = llmq::QuorumProofChain::Decode(ParseHex(TESTNET_PROOF)); + const auto target = proof.Verify(proof.anchor); + auto changed = proof; + changed.seedPath.siblings.front() = uint256::ONE; + BOOST_CHECK_THROW(changed.Verify(proof.anchor), std::exception); + changed = proof; + changed.seed.push_back(0); + BOOST_CHECK_THROW(changed.Verify(proof.anchor), std::exception); + changed = proof; + changed.anchor.height = proof.links.front().certificate.height; + BOOST_CHECK_THROW(changed.Verify(changed.anchor), std::exception); + changed = proof; + changed.links[1].mining.path.siblings.front() = uint256::ONE; + BOOST_CHECK_THROW(changed.Verify(proof.anchor), std::exception); + changed = proof; + changed.links[1].ancestors.clear(); + BOOST_CHECK_THROW(changed.Verify(proof.anchor), std::exception); + changed = proof; + changed.coinbase.path.siblings.front() = uint256::ONE; + BOOST_CHECK_THROW(changed.Verify(proof.anchor), std::exception); + BOOST_CHECK(proof.Verify(proof.anchor) == target); +} +BOOST_AUTO_TEST_CASE(v20_checkpoint_height_is_supported) +{ + auto proof = llmq::QuorumProofChain::Decode(ParseHex(TESTNET_PROOF)); + proof.anchor.height = 905100; + BOOST_CHECK_NO_THROW(proof.Verify(proof.anchor)); +} +BOOST_AUTO_TEST_CASE(concurrent_proof_cache_requests) +{ + std::vector> requests; + for (int i = 0; i < 8; ++i) { + requests.push_back(std::async(std::launch::async, [] { + // Each request owns its BLS objects; only the caches are shared. + const auto proof = llmq::QuorumProofChain::Decode(ParseHex(TESTNET_PROOF)); + for (int j = 0; j < 16; ++j) { + if (proof.Verify(proof.anchor).height != proof.target.height) return false; + auto changed = proof; + changed.target.header.nNonce ^= 1; + try { + changed.Verify(proof.anchor); + return false; + } catch (const std::exception&) { + } + } + return true; + })); + } + for (auto& request : requests) + BOOST_CHECK(request.get()); +} +BOOST_AUTO_TEST_CASE(cumulative_header_budget) { + auto proof = llmq::QuorumProofChain::Decode(ParseHex(TESTNET_PROOF)); + proof.links[0].ancestors.resize(llmq::MAX_PROOF_HEADERS); + BOOST_CHECK_THROW(proof.Encode(), std::exception); + BOOST_CHECK_THROW(proof.Verify(proof.anchor), std::exception); +} +BOOST_AUTO_TEST_CASE(positional_merkle_shape_and_mutation) { + std::vector leaves{uint256::ONE, uint256S("02"), uint256S("03")}; + auto root = Hash(Hash(leaves[0], leaves[1]), Hash(leaves[2], leaves[2])); + auto path = llmq::ProofMerklePath::Build(leaves, 2); + BOOST_CHECK(path.Verify(leaves[2], root)); + path.count = 4; + BOOST_CHECK(!path.Verify(leaves[2], root)); + path = llmq::ProofMerklePath::Build({leaves[0]}, 0); + BOOST_CHECK(path.Verify(leaves[0], leaves[0])); + path.siblings.push_back(leaves[0]); + BOOST_CHECK(!path.Verify(leaves[0], Hash(leaves[0], leaves[0]))); + BOOST_CHECK_THROW(llmq::ProofMerklePath::Build(leaves, 3), std::exception); +} +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/util/data/non-backported.txt b/test/util/data/non-backported.txt index e06e682ac56a..c05f1824fe80 100644 --- a/test/util/data/non-backported.txt +++ b/test/util/data/non-backported.txt @@ -77,6 +77,7 @@ src/test/dynamic_activation*.cpp src/test/evo*.cpp src/test/fuzz/dkg_message_framing.cpp src/test/llmq*.cpp +src/test/quorum_proofs_tests.cpp src/test/masternode_payments_tests.cpp src/test/spork_tests.cpp src/test/unordered_lru_cache_tests.cpp From d251014485fffeff98cdf6717ba33bbf06740fa0 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Sep 2026 00:40:05 -0500 Subject: [PATCH 03/10] feat(rpc): expose snapshot proof generation and verification --- doc/release-notes-7107.md | 8 + src/rpc/client.cpp | 7 + src/rpc/quorums.cpp | 243 ++++++++++++++++++ test/functional/data/quorum_proof.json | 17 ++ test/functional/feature_llmq_chainlocks.py | 14 + test/functional/feature_quorum_proof_chain.py | 68 +++++ test/functional/test_runner.py | 1 + 7 files changed, 358 insertions(+) create mode 100644 doc/release-notes-7107.md create mode 100644 test/functional/data/quorum_proof.json create mode 100755 test/functional/feature_quorum_proof_chain.py diff --git a/doc/release-notes-7107.md b/doc/release-notes-7107.md new file mode 100644 index 000000000000..d3b4fd741e2f --- /dev/null +++ b/doc/release-notes-7107.md @@ -0,0 +1,8 @@ +New RPCs +-------- + +- Add `getquorumproofchain` and `verifyquorumproofchain` for compact mining-transaction + proofs from an independently trusted snapshot, plus `getchainlockbyheight` for + historical coinbase-carried certificates. The RPCs read required blocks on + demand, without an additional index or startup scan. Nodes must retain the + required historical blocks to generate proofs. (#7107) diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 972e99c94924..ca34d732b346 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -330,6 +330,11 @@ static const CRPCConvertParam vRPCConvertParams[] = { "protx update_service_evo", 5, "platformP2PAddrs", true }, { "protx update_service_evo", 6, "platformHTTPSAddrs", true }, { "protx update_service_evo", 9, "submit" }, + { "getchainlockbyheight", 0, "height" }, + { "getquorumproofchain", 1, "height" }, + { "getquorumproofchain", 2, "quorum_hash" }, + { "getquorumproofchain", 3, "llmq_type" }, + { "getquorumproofchain", 4, "node_count" }, { "quorum dkgsimerror", 2, "rate" }, { "quorum dkgstatus", 1, "detail_level" }, { "quorum getdata", 1, "nodeId" }, @@ -351,6 +356,8 @@ static const CRPCConvertParam vRPCConvertParams[] = { "quorum sign", 5, "submit" }, { "quorum verify", 1, "llmqType" }, { "quorum verify", 6, "signHeight" }, + { "verifyquorumproofchain", 0, "checkpoint" }, + { "verifyquorumproofchain", 2, "minimum_height" }, }; // clang-format on diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 5bd168e9b0bf..26bf85df3a11 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -15,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -1390,6 +1393,243 @@ static RPCHelpMan submitchainlock() } +static RPCHelpMan getchainlockbyheight() +{ + return RPCHelpMan{ + "getchainlockbyheight", + "Read a historical ChainLock from coinbases on disk.\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); + CChain chain; + CBlockIndex* tip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); + CHECK_NONFATAL(tip != nullptr); + chain.SetTip(*tip); + try { + chainlock::CoinbaseChainLockReader reader(chain); + const auto entry = reader.Find(height, height); + if (!entry) throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Chainlock not found for height"); + { + LOCK(cs_main); + if (!chainman.ActiveChain().Contains(entry->carrier)) + throw JSONRPCError(RPC_MISC_ERROR, "Chain changed during ChainLock lookup; retry"); + } + UniValue result(UniValue::VOBJ); + result.pushKV("height", height); + result.pushKV("blockhash", entry->clsig.getBlockHash().ToString()); + result.pushKV("signature", entry->clsig.getSig().ToString()); + result.pushKV("cbtx_height", entry->carrier->nHeight); + return result; + } catch (const std::exception& e) { + throw JSONRPCError(RPC_MISC_ERROR, e.what()); + } + }, + }; +} + +static RPCHelpMan getquorumproofchain() +{ + return RPCHelpMan{ + "getquorumproofchain", + "Generate a DASHNC02 mining-transaction proof and authenticated record openings. Reads required historical " + "blocks on demand.\n", + { + {"checkpoint_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Release checkpoint block hash"}, + {"height", RPCArg::Type::NUM, RPCArg::Default{0}, + "Minimum certified target height; zero selects the latest available ChainLock"}, + {"quorum_hash", RPCArg::Type::STR, RPCArg::Default{""}, "Optional Platform quorum hash to open"}, + {"llmq_type", RPCArg::Type::NUM, RPCArg::Default{0}, "Required with quorum_hash"}, + {"node_count", RPCArg::Type::NUM, RPCArg::Default{0}, "Number of eligible EvoNode records (0..15)"}, + }, + RPCResult{RPCResult::Type::OBJ, + "", + "", + { + {RPCResult::Type::STR_HEX, "proof_hex", "DASHNC02 bytes"}, + {RPCResult::Type::STR_HEX, "bootstrap_hex", "Proof and record openings; empty when no records requested"}, + {RPCResult::Type::OBJ, "target", "Authenticated target state", {{RPCResult::Type::ELISION, "", ""}}}, + }}, + RPCExamples{HelpExampleCli("getquorumproofchain", "\"checkpoint_hash\"")}, + [&](const RPCHelpMan&, const JSONRPCRequest& request) -> UniValue { + const auto& node = EnsureAnyNodeContext(request.context); + const auto& ctx = EnsureLLMQContext(node); + const auto& chainman = EnsureChainman(node); + const auto anchorHash = ParseHashV(request.params[0], "checkpoint_hash"); + const int32_t minimum = request.params[1].isNull() ? 0 : request.params[1].getInt(); + const auto quorumText = request.params[2].isNull() ? std::string{} : request.params[2].get_str(); + const int type = request.params[3].isNull() ? 0 : request.params[3].getInt(); + const int nodeCount = request.params[4].isNull() ? 0 : request.params[4].getInt(); + if (minimum < 0 || type < 0 || type > 255 || nodeCount < 0 || nodeCount > 15 || + (quorumText.empty() != (type == 0)) || (!quorumText.empty() && (quorumText.size() != 64 || !IsHex(quorumText)))) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid proof request"); + } + CChain chain; + const CBlockIndex* checkpoint; + CBlockIndex* tip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); + CHECK_NONFATAL(tip != nullptr); + chain.SetTip(*tip); + { + LOCK(cs_main); + checkpoint = chainman.m_blockman.LookupBlockIndex(anchorHash); + if (!checkpoint || !chain.Contains(checkpoint)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "Checkpoint is not on the active chain"); + } + try { + chainlock::CoinbaseChainLockReader reader(chain); + const int64_t start = std::max(minimum, int64_t(checkpoint->nHeight) + 1); + std::optional target_chainlock; + if (minimum == 0) { + target_chainlock = reader.Read(chain.Height()); + } else if (start <= chain.Height()) { + target_chainlock = reader.Find(int(start), int(std::min(chain.Height(), + start + int64_t(llmq::MAX_PROOF_HEADERS)))); + } + chainlock::ChainLockSig target_signature; + const CBlockIndex* target_guard{nullptr}; + if (target_chainlock) { + target_signature = target_chainlock->clsig; + target_guard = target_chainlock->carrier; + } + // Platform can already reference a tip ChainLock before another + // block embeds it. The existing manager supplies the same final + // certificate; historical handoffs still come from disk. + if (minimum == 0 || !target_chainlock) { + const auto live = CHECK_NONFATAL(node.chainlocks)->GetBestChainLock(); + const auto* live_index = chain[live.getHeight()]; + if (live_index && live_index->GetBlockHash() == live.getBlockHash() && live.getHeight() >= start && + live.getHeight() > target_signature.getHeight() && + (minimum == 0 || int64_t(live.getHeight()) <= start + int64_t(llmq::MAX_PROOF_HEADERS))) { + target_signature = live; + target_guard = live_index; + } + } + if (!target_guard || target_signature.getHeight() <= checkpoint->nHeight) + throw std::runtime_error("No archived certificate within search budget"); + const auto* target = chain[target_signature.getHeight()]; + llmq::QuorumProofBuilder builder(*ctx.quorum_block_processor, *ctx.qman, chain, chainman, reader); + auto proof = builder.Build(checkpoint, target_signature); + std::vector records; + if (!quorumText.empty()) { + const auto hash = uint256S(quorumText); + const auto commitments = builder.ActiveCommitments(target); + std::vector leaves; + for (const auto& commitment : commitments) + leaves.push_back(SerializeHash(commitment)); + bool found = false; + for (size_t i = 0; i < commitments.size(); ++i) { + const auto& commitment = commitments[i]; + if (commitment.quorumHash != hash || int(commitment.llmqType) != type) continue; + CDataStream raw(SER_NETWORK, PROTOCOL_VERSION); + raw << commitment; + records.push_back({0, {UCharCast(raw.data()), UCharCast(raw.data()) + raw.size()}, llmq::ProofMerklePath::Build(leaves, i)}); + found = true; + break; + } + if (!found) throw std::runtime_error("Requested quorum is not in the target root"); + } + if (nodeCount > 0) { + const auto list = WITH_LOCK(cs_main, return CHECK_NONFATAL(node.dmnman)->GetListForBlock(target)); + const auto sml = list.to_sml(); + std::vector leaves; + for (const auto& entry : sml->mnList) + leaves.push_back(entry->CalcHash()); + int included = 0; + for (size_t i = 0; i < sml->mnList.size() && included < nodeCount; ++i) { + const auto& entry = *sml->mnList[i]; + if (!entry.isValid || entry.confirmedHash.IsNull() || entry.nType != MnType::Evo) continue; + // Exactly CalcHash's serialization, without the network-only version prefix. + CDataStream raw(SER_GETHASH, CLIENT_VERSION); + raw << entry; + records.push_back({1, {UCharCast(raw.data()), UCharCast(raw.data()) + raw.size()}, llmq::ProofMerklePath::Build(leaves, i)}); + ++included; + } + if (included == 0) throw std::runtime_error("No eligible EvoNode records at target"); + } + { + LOCK(cs_main); + if (!chainman.ActiveChain().Contains(target_guard)) + throw std::runtime_error("Chain changed during proof construction; retry"); + } + UniValue result(UniValue::VOBJ); + result.pushKV("proof_hex", HexStr(proof.Encode())); + result.pushKV("bootstrap_hex", records.empty() ? "" : HexStr(llmq::EncodeBootstrap(proof, records))); + result.pushKV("target", proof.Verify(proof.anchor).ToJson()); + return result; + } catch (const std::exception& e) { + throw JSONRPCError(RPC_MISC_ERROR, e.what()); + } + }}; +} + +static RPCHelpMan verifyquorumproofchain() +{ + return RPCHelpMan{"verifyquorumproofchain", + "Verify DASHNC02 against an independently trusted full snapshot. Never obtains trust roots from proof data.\n", + { + {"checkpoint", RPCArg::Type::OBJ, RPCArg::Optional::NO, "Trusted snapshot", { + {"network", RPCArg::Type::NUM, RPCArg::Optional::NO, "0 mainnet, 1 testnet"}, + {"height", RPCArg::Type::NUM, RPCArg::Optional::NO, "Snapshot height"}, + {"block_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Snapshot hash"}, + {"masternode_root", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Masternode root"}, + {"quorum_root", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Quorum root"}, + }}, + {"proof_hex", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "DASHNC02 proof"}, + {"minimum_height", RPCArg::Type::NUM, RPCArg::Default{0}, "Caller freshness policy"}, + }, + RPCResult{RPCResult::Type::OBJ, "", "", { + {RPCResult::Type::BOOL, "valid", "Whether verification succeeded"}, + {RPCResult::Type::OBJ, "target", true, "Authenticated target", {{RPCResult::Type::ELISION, "", ""}}}, + {RPCResult::Type::STR, "error", true, "Verification error"}, + }}, + RPCExamples{HelpExampleCli("verifyquorumproofchain", "'{\"network\":0,\"height\":1987776,\"block_hash\":\"\",\"masternode_root\":\"\",\"quorum_root\":\"\"}' \"\"")}, + [&](const RPCHelpMan&, const JSONRPCRequest& request) -> UniValue { + llmq::ProofState trusted; + std::string text; + uint32_t minimum{0}; + try { + trusted = llmq::ProofState::FromJson(request.params[0]); + text = request.params[1].get_str(); + minimum = request.params[2].isNull() ? 0 : request.params[2].getInt(); + } catch (const std::exception& e) { + throw JSONRPCError(RPC_INVALID_PARAMETER, e.what()); + } + if (text.size() > llmq::MAX_PROOF_BYTES * 2 || !IsHex(text)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Proof hex size/encoding"); + } + + UniValue result(UniValue::VOBJ); + try { + auto proof = llmq::QuorumProofChain::Decode(ParseHex(text)); + auto target = proof.Verify(trusted); + if (target.height < minimum) throw std::runtime_error("Stale proof target"); + result.pushKV("valid", true); + result.pushKV("target", target.ToJson()); + } catch (const std::exception& e) { + result.pushKV("valid", false); + result.pushKV("error", e.what()); + } + return result; + }}; +} + void RegisterQuorumsRPCCommands(CRPCTable& t) { static const CRPCCommand commands[]{ @@ -1413,6 +1653,9 @@ void RegisterQuorumsRPCCommands(CRPCTable& t) {"evo", &submitchainlock}, {"evo", &verifychainlock}, {"evo", &verifyislock}, + {"evo", &getchainlockbyheight}, + {"evo", &getquorumproofchain}, + {"evo", &verifyquorumproofchain}, }; for (const auto& command : commands) { t.appendCommand(command.name, &command); diff --git a/test/functional/data/quorum_proof.json b/test/functional/data/quorum_proof.json new file mode 100644 index 000000000000..5e230120299a --- /dev/null +++ b/test/functional/data/quorum_proof.json @@ -0,0 +1,17 @@ +{ + "checkpoint": { + "network": 1, + "height": 1548500, + "block_hash": "000000a99c2dac4616bca1f27301f1f99684a96102c97ef1d645569c529b55b7", + "masternode_root": "c0f49c8c3d91c16d9bcc7ce08345b3279901d0c8da66c2620b7acc9d1ed38021", + "quorum_root": "ba4b901be120adc613b38914a09f3745de1112fa9b49d34d6b435643fd99ab1c" + }, + "target": { + "network": 1, + "height": 1549547, + "block_hash": "0000006c4949598a2dab62d679834121646a225fc6e8cd44c39d8ee9f8c19771", + "masternode_root": "c0f49c8c3d91c16d9bcc7ce08345b3279901d0c8da66c2620b7acc9d1ed38021", + "quorum_root": "6349769daa7cfbe4c61e9879c8ffe282076dff7759860a5d9b3a689c8c627a4f" + }, + "proof_hex": "444153484e43303201d4a01700b7559b529c5645d6f17ec90261a98496f9f10173f2a1bc1646ac2d9ca90000002180d31e9dcc7a0b62c266dac8d0019927b34583e07ccc9b6dc1913d8c9cf4c01cab99fd4356436b4dd3499bfa1211de45379fa01489b313c6ad20e11b904bba43010000030001f377b58af19b177c75bfc1c2c7ceb962911a2b68100db7bc4f50f988a700000032ffffffffffff0332ffffffffffff03b39c46eec719125f7d94b727669dada09c11f982eed537b3ebf2695745428e883f87787d096f3dd339d6897313b4517197f27eb5c29258b07e0f41b44c9cdc7180f241c0b20fb749cd029cfef29e0230a850f28c42611d27c6987a461a38b348360a7f40f8ecbedb6c4f3114ed3ee0a074d20d8eac81a126e25bc17d65b2b01f010e97fdcd5a64dcf72ac039da682641cd4f73ab7c77962ef770e146fe55d632192a8dcebbead81d4b6f0a71b8e010118c01d8a9e1c9ba466615e6a52fb8508d30a687808a13d05a3019588fca4d5d76a3b54e7776e3b0c7b8fc010375a147d017a6b995e66c6ec77f144bdc8bdffd41e924bc0071fd940c0a89083ee1841481ada9e0a925f6db0f0103fc277da18885020000006d000000070bbad189bcdb405fc4f337d3155f9a9254788edc95b3b8e85cdde5408655132bd95fff59b78300333e920f14c85318e3a08f151390c9db07ea90e95a3dcd4a17335881e7ef79427373278c5d75db84b88db1b932a98280f0f63816c49bba7680356cc8d193deaafc50abb4ed43c15cea4d4e4617884b013897020d2fa7cce9370fbf179099dfa4ff693ca19a4b5969ead9a032a89de35b2cdf296b21330b6760a8660185d2dcf378464c4f1a41f9a23e8b60a4c2d81c942ec50bb6641b394dd878f4e1d26fbee716ee68ded7466648d77b2b027cf2a68f14ae1df48686c4f1440300e3a0170000000020ffe8a875515f906d4e01c3afd3eb4539ac9bb722486636acce9bec0f51000000c66f1236ae22de4015406a059626031296d5ea5258a871ff8e32afefd6ca4af52b519d6a6fa8001ef4b30600ac4d9c40b3dc3f3ef3ce00ca9d3383cd31da10271b3c77f6ca13fae18eb52174bb3f4f9e0dc501c0f53d9b0c7d99aa870101e2c9898facc101097207d4882f7fb763255f9b73c306c0ce39aa6324449f5e53203fd6966f1afc3214a3a079778b5601000003000600000000000000fd49010100e3a017000300013febcaedd1db22c960be4055a715f953a636c3ad24497cadf23b416e1c00000032ffffffffffff0332ffffffffffff038d2b0a51b13fd14ba80f1a0322807e65310c28c35c5a97d812abaec677961e9ff918e742e977b7a9196d11437c60d1bf06e11160c25112684c0713d7d9347f2568cf000a5c3414e16ca41516243d59b495434dc61c1c86e60a1277976ba427135a05a3d45ee68dd2cd9de705c843866c1c4537907c72570a976b03184906f6a71667926727aaae73006d3334d718604782eb3fa5b162bf1c6f4319083ecb7bf5a1940315f41a8b16de92e3727714661c82523b37f119ccfd2cb08268f3b41d80208116309117f9c7ea3c3c63a24ebe7c02b89a5aad8fea24025805232197889f02b585abcef3949129f4ec9d0df597ebfcb0fe52a4269cc143a2fb2b60ded5b556ea3307d9edf0ce3cd2d3cb90cb472c01000000040000000279ca353e0de915e395676997024b602d3856e940a413ed9c6f1d0c5defdd050fa59444f0db90dcfb4abebb6b2181bc36afc13b8c17a0066810edbc248f9e3ada00001ca217000000002049519f8cc02a899b62f3d9d582b379afa66ca83389aef217545be47e3b000000261154fda3fba0a9490d6772eeeb5ce019d8b1be03f085e8c2be88f71e1a2c3b06f99d6ae4cd001e20460e00ab179f752254ba2100f5059eaaaff27aa58f5a3b11b7aeb2c24a7e1c1693d0a3f2ba330adf3df620d29fe3d7afeb9de80b834c05f6a4be6ce1f0a9962972553c50265023a50cf6db2afb40d1f936bf957eeeeafaa74a34a7fcbb451c1bc54a7c5601000003000600000000000000fd490101001ba2170003000138e92399565737d10b9a47ce26d3742f1ee77b66d5894530eb00d37cdb00000032ffffffffffff0332ffffffffffff03ae0caf5a868060c4efc24485f0f52a1552d1ed8b380fede440e5886242ba82dcb79c5edcb31a09e08eade03c6caeb60d0026d7812596a8806df233b87070dc759ca432a0d5ac8b397bdf5c81a55dc954a42d9575d5b39d099b7a15fbad36db6cde0ff0bf58a9949530a9403f49150c323e528c0a410e02c076884885ebcc846e18995c6ca7152824988505542ff6cbc45d32c09fb5c4e2c54acbf7d3d2c3903d641ad75bb0b5e683f33f89fdeb0133469155424ac7247828917f2670edd70484c8447c7917b143125a01486e304de89218fe5b9780acb733d6d9c8127b688ae60233a382bab13dc36e26f8af7f6c80b1dc3fed2a4ea5e0e83192ce69a7d6d199e1104255174f4cbbb8ad8a54283e874901000000040000000209916264ab2466c1d9ff4863293b5872582ed9d91c3457db8661cd4536da68c066fecf371fcf1c13a7d810e200172086a9a469e2a5e3095a40b322964771085c01000000002015d04f67aef05968f56c42835c3ee9ba14ff7e1845bd79ffa98d5abb2a000000d4c6a527e26aa5ff70cbacbdb13feaa62a7dff1713b2eb0d81283eaa160c0ca59ff79d6a3bc9001ecfc70c0047a41700000000202817b51778f715cd893af91961d4ca111d799efed31d386f5b6595f504000000962e5f6fae97a06781f0a95278511094c1f2fa4df863c426e73cd20b6ee373300e299f6ad938011ec05d0e0091cfab11aa40b0d23f47162d72ce0f074279de31c3ce266c0d06b5aa2699e1b8880cf8308c8634cec380132541ecfe2b1878d43fcde1bd987b93bc2e59fd121526f92bb0df05d114ee4d1122d0d435668c84bd5972165b3dbd121f70254d101b5601000003000600000000000000fd4901010043a41700030001d21f6a8ab554a886922406a9731081e7da5ded14f4ac2f9eb1ec3edd7700000032ffffffffffff0332ffffffffffff03818bd5c7cc6914422d19f6cacf90f85fccc591a38d392378d07bfd25fc48437c59998b657253a0c076e6037a564da0faff27d1174b74f2c5b68e4fdae4dd193c8bdee18d218e3ce061d3ac75726dbf0eb9395e2e1e07dd4757bde426b8f6a9254d4f50d1c48eb51c4ff26415c294bf6e2196db7da343a0a85221ae04025fb726127349b23f6e8c9c9cc8c7ccbf81b24e1c69b322eddc8bfa3c17f2cc7c7a983568e5ba9e8544e93456a70ee2ddd758feaf51515b5c05084a9e68b167f57d84dc43ed963ede7e34d879b4205a193b30d35fdceade18b606a1aa9c19e03f9048f00abc96d88309a9f06d427699caee299ba672b9ecbbba19e72f61171cbe95451a9882240976c652390830c39a03e408f8010000000500000003e981e0af4ced7c8d0ca3e50e9c438f8ab44dbdbd0b3f6145e95a55cdd262bbd8c2f12457d5fcdda836c8e8b872d3d35f3872551e557c6385016e40b963e56cebfa5b9e96f4bc7976d1a57b911054844ae039a821ece72a881a7ae0dc1715d2380400000000203c143d3b0c2a6025fd1fc1e86536d54b6caddbbd5ccca5530d0e326f9001000068f398ba844230743ba54b449e7a18e7ad2cc69f831356a2bdf3f0147afb2eef5a269f6aff5b011e2a7e010000000020405d948621803e2dea6e2674ac9ece02dc406d3ae6c57223c8d88058190000001738c1b5f920940752bfa7dd408b1e05a2cc413513223eccf70d2ec8d03fbbce24279f6af816011ebcdb0c00000000207f02cb0da8cd8a368924e46eaca70cac0e35d53f37f8a6905955567c930000006e145b97802c84d157023105d8a8f847e2952b29c8656f7f1343e9e186f0d51168289f6adb21011eed440a0000000020bcc820660fc954d5b5b163a934be5c2f5047948e92279a46874d816522000000dfda21c0255e83d9df7f6d31ef5521fa150f6f40caca08d095ff489a14307606c4289f6a1f31011e071c0900eba4170000000020ca2ba845159ffc0acbfa5b60b8cc2109f8987e66622f489458ed88a26c000000a09092e006bd3582faaba706775243c0c468f79cad4490e1cf537cf641cdff3db97e9f6a18a4001e78dd0e00a343ee60c14943a149848f7886a138d0616880abfeefc27fa0e0da9c1cae8835d5fad377095d298164cfffe9f5cdd6ec0a37d61a32aa9bab719faa15881aa13ede275cf1a8eefd6789bffe1df6ec4759e1035f0dab3e2305016aa9e874f5ef063201000003000500010000000000000000000000000000000000000000000000000000000000000000ffffffff016affffffff03b84b8c03000000001976a914b489115851ca07a26a5ad8bac3cec3c7dbebd83188ac2ed5fd0300000000016af80da706000000001976a91464f2b2b84f62d68a2cd7f7f5fb2b5aa75ef716d788ac00000000af0300eba417002180d31e9dcc7a0b62c266dac8d0019927b34583e07ccc9b6dc1913d8c9cf4c04f7a628c9c683a9b5d0a865977ff6d0782e2ffc879981ec6e4fb7caa9d76496300b59e80b1894fe733fad1e8316070314fe024b93d9b7b51baf63b2642d3d89495ee4bc69c68ec225e7eaa191719846fd00c0d5421047df2b42eac1386d908c3e755f642d3055c5c3a75b85500b358dce7cc5a17730ad9c75ced9c9e98c491e98a9465d733de200000000000000400000002b3336e59b1bc4bdccc3397c24a85c882184d2fb8d6f0a563d19100cadc53bdeb4d864dd73362cbcda1ec96a3f33ad9678edcd1ff85bb3f2581b8d54d57685615" +} diff --git a/test/functional/feature_llmq_chainlocks.py b/test/functional/feature_llmq_chainlocks.py index 44861b622dff..14ede44a9043 100755 --- a/test/functional/feature_llmq_chainlocks.py +++ b/test/functional/feature_llmq_chainlocks.py @@ -65,6 +65,13 @@ def run_test(self): self.mine_cycle_quorum() self.wait_for_chainlocked_block_all_nodes(self.nodes[0].getbestblockhash()) + # No later coinbase can carry the current tip's signature yet. Reaching + # the network gate proves the RPC selected the manager's live ChainLock; + # complete proof generation supports mainnet/testnet, not regtest. + node = self.nodes[0] + assert_raises_rpc_error(-1, "proofs support mainnet/testnet", node.getquorumproofchain, + node.getblockhash(1), node.getblockcount()) + self.log.info("Mine single block, ensure it includes latest chainlock") self.generate(self.nodes[0], 1, sync_fun=self.sync_blocks) self.test_coinbase_best_cl(self.nodes[0]) @@ -292,6 +299,13 @@ def test_coinbase_best_cl(self, node, expected_cl_in_cb=True, expected_null_cl=F target_block_hash = node.getblockhash(best_cl_height) # Verify CL signature assert node.verifychainlock(target_block_hash, best_cl_signature, best_cl_height) + historical = node.getchainlockbyheight(best_cl_height) + assert_equal(historical["height"], best_cl_height) + assert_equal(historical["blockhash"], target_block_hash) + assert_equal(historical["signature"], best_cl_signature) + assert historical["cbtx_height"] <= cb_height + carrier = node.getblock(node.getblockhash(historical["cbtx_height"]), 2)["cbTx"] + assert_equal(int(carrier["height"]) - int(carrier["bestCLHeightDiff"]) - 1, best_cl_height) else: assert "bestCLHeightDiff" not in cbtx and "bestCLSignature" not in cbtx diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py new file mode 100755 index 000000000000..468513ca0d0f --- /dev/null +++ b/test/functional/feature_quorum_proof_chain.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025-2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Verify real testnet mining proofs via RPC with an independent checkpoint.""" +import json +from pathlib import Path + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal, assert_raises_rpc_error + + +class QuorumProofChainTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + self.supports_cli = False # Exercises HTTP batches and explicit CLI calls. + + def run_test(self): + node = self.nodes[0] + fixture = json.loads((Path(__file__).parent / "data/quorum_proof.json").read_text()) + anchor, proof = fixture["checkpoint"], fixture["proof_hex"] + result = node.verifyquorumproofchain(anchor, proof) + assert_equal(result, {"valid": True, "target": fixture["target"]}) + assert_raises_rpc_error(-8, "height must be non-negative", node.cli.getchainlockbyheight, -1) + assert_raises_rpc_error(-8, "height must be non-negative", node.cli.getchainlockbyheight, height=-1) + assert_raises_rpc_error(-1, "No archived certificate", node.cli.getquorumproofchain, + checkpoint_hash=node.getbestblockhash(), height=0, llmq_type=0, node_count=4) + assert_equal(node.cli.verifyquorumproofchain(checkpoint=anchor, proof_hex=proof, minimum_height=0), result) + + # An unavailable archive request must not prevent independent verification + # in the same batch. This fixture has no archived ChainLock. + requests = [ + node.getquorumproofchain.get_request(checkpoint_hash=node.getbestblockhash()), + node.getchainlockbyheight.get_request(height=0), + node.verifyquorumproofchain.get_request(checkpoint=anchor, proof_hex=proof), + ] + responses = {response["id"]: response for response in node.batch(requests)} + assert_equal(responses[requests[0]["id"]]["error"]["code"], -1) + assert "No archived certificate" in responses[requests[0]["id"]]["error"]["message"] + assert_equal(responses[requests[1]["id"]]["error"], + {"code": -5, "message": "Chainlock not found for height"}) + assert_equal(responses[requests[2]["id"]]["error"], None) + assert_equal(responses[requests[2]["id"]]["result"], result) + assert_equal(node.verifyquorumproofchain(anchor, proof, fixture["target"]["height"] + 1)["valid"], False) + wrong = dict(anchor, quorum_root="01" * 32) + assert_equal(node.verifyquorumproofchain(wrong, proof)["valid"], False) + for invalid in (proof[:-2], proof + "00", "444153484e433031" + proof[16:]): + assert_equal(node.verifyquorumproofchain(anchor, invalid)["valid"], False) + assert_raises_rpc_error(-8, "Proof hex size/encoding", node.verifyquorumproofchain, anchor, "00" * 1048577) + assert_raises_rpc_error(-8, "snapshot fields missing", node.verifyquorumproofchain, + {"height": anchor["height"]}, proof) + assert_raises_rpc_error(-8, None, node.verifyquorumproofchain, anchor, proof, -1) + tampered = bytearray.fromhex(proof) + tampered[-20] ^= 1 + assert_equal(node.verifyquorumproofchain(anchor, tampered.hex())["valid"], False) + assert_raises_rpc_error(-1, "No archived certificate", node.getquorumproofchain, node.getbestblockhash()) + assert_raises_rpc_error(-8, "Invalid proof request", node.getquorumproofchain, node.getbestblockhash(), 0, "", 0, 16) + self.generatetoaddress(node, 440, node.get_deterministic_priv_key().address) + self.restart_node(0) + assert_equal(node.verifyquorumproofchain(anchor, proof)["valid"], True) + assert_raises_rpc_error(-1, "No archived certificate", node.getquorumproofchain, node.getbestblockhash()) + self.restart_node(0) + assert_equal(node.verifyquorumproofchain(anchor, proof)["valid"], True) + + +if __name__ == "__main__": + QuorumProofChainTest().main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 8563d10f7861..e6bcddff19a4 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -116,6 +116,7 @@ 'feature_llmq_is_retroactive.py', # NOTE: needs dash_hash to pass 'feature_llmq_chainlocks.py', # NOTE: needs dash_hash to pass 'feature_llmq_chainlocks_automatic.py', # NOTE: needs dash_hash to pass + 'feature_quorum_proof_chain.py', 'feature_masternode_payout_shares.py', 'feature_llmq_signing.py --spork21', # NOTE: needs dash_hash to pass 'feature_llmq_simplepose.py --disable-spork23', # NOTE: needs dash_hash to pass From 134f0030338d9f6fe0773fbb03c4f2b2c9a6dd66 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Sep 2026 09:58:57 -0500 Subject: [PATCH 04/10] perf(rpc): reuse chain snapshots for proof lookups --- src/chainlock/clsig.cpp | 15 +++++++++++---- src/chainlock/clsig.h | 8 +++----- src/rpc/quorums.cpp | 28 +++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/chainlock/clsig.cpp b/src/chainlock/clsig.cpp index 02964a925197..90bd81be27d2 100644 --- a/src/chainlock/clsig.cpp +++ b/src/chainlock/clsig.cpp @@ -21,9 +21,14 @@ namespace chainlock { static constexpr std::string_view CLSIG_REQUESTID_PREFIX{"clsig"}; static constexpr size_t MAX_HISTORICAL_CARRIER_READS{16384}; +CoinbaseChainLockReader::CoinbaseChainLockReader(const CChain& chain) : + m_tip(chain.Tip()) +{ +} + std::optional CoinbaseChainLockReader::Read(int carrier_height) { - const auto* carrier = m_chain[carrier_height]; + const auto* carrier = m_tip ? m_tip->GetAncestor(carrier_height) : nullptr; if (!carrier || carrier_height < Params().GetConsensus().V20Height) return std::nullopt; if (const auto it = m_cache.find(carrier_height); it != m_cache.end()) return it->second; if (ShutdownRequested()) throw std::runtime_error("ChainLock lookup interrupted"); @@ -46,18 +51,20 @@ std::optional CoinbaseChainLockReader::Read(int carrier_heigh return m_cache.emplace(carrier_height, std::nullopt).first->second; } const int height = carrier_height - int(chainlock->second) - 1; + const auto* signed_block = m_tip->GetAncestor(height); + if (!signed_block) throw std::runtime_error("Invalid historical ChainLock block height"); return m_cache .emplace(carrier_height, - CoinbaseChainLock{ChainLockSig{height, m_chain[height]->GetBlockHash(), chainlock->first}, carrier}) + CoinbaseChainLock{ChainLockSig{height, signed_block->GetBlockHash(), chainlock->first}, carrier}) .first->second; } std::optional CoinbaseChainLockReader::Find(int minimum_height, int maximum_height) { - if (minimum_height < 0 || minimum_height > maximum_height || minimum_height >= m_chain.Height()) + const int tip_height = m_tip ? m_tip->nHeight : -1; + if (minimum_height < 0 || minimum_height > maximum_height || minimum_height >= tip_height) return std::nullopt; const int first_carrier = std::max(minimum_height + 1, Params().GetConsensus().V20Height); - const int tip_height = m_chain.Height(); if (first_carrier > tip_height) return std::nullopt; // Empty carriers are possible, so the predicate used by a binary search is diff --git a/src/chainlock/clsig.h b/src/chainlock/clsig.h index 8aca03250700..f7c02a456b97 100644 --- a/src/chainlock/clsig.h +++ b/src/chainlock/clsig.h @@ -36,14 +36,12 @@ struct CoinbaseChainLock { */ class CoinbaseChainLockReader { - const CChain& m_chain; + const CBlockIndex* m_tip; std::map> m_cache; public: - explicit CoinbaseChainLockReader(const CChain& chain) : - m_chain(chain) - { - } + explicit CoinbaseChainLockReader(const CChain& chain); + explicit CoinbaseChainLockReader(const CBlockIndex* tip) : m_tip(tip) {} std::optional Read(int carrier_height); /** First certificate at or above minimum_height, limited by maximum_height. */ std::optional Find(int minimum_height, int maximum_height); diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 26bf85df3a11..1337832f7c5d 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -41,11 +41,31 @@ #include #include +#include +#include #include using node::GetTransaction; using node::NodeContext; +namespace { +std::mutex g_proof_chain_mutex; +std::shared_ptr g_proof_chain_snapshot; +const CBlockIndex* g_proof_chain_tip{nullptr}; + +std::shared_ptr GetProofChainSnapshot(CBlockIndex* tip) +{ + std::lock_guard lock(g_proof_chain_mutex); + if (!g_proof_chain_snapshot || g_proof_chain_tip != tip) { + auto snapshot = std::make_shared(); + snapshot->SetTip(*tip); + g_proof_chain_snapshot = std::move(snapshot); + g_proof_chain_tip = tip; + } + return g_proof_chain_snapshot; +} +} // namespace + static RPCHelpMan quorum_list() { return RPCHelpMan{"quorum list", @@ -1419,12 +1439,10 @@ static RPCHelpMan getchainlockbyheight() const NodeContext& node = EnsureAnyNodeContext(request.context); const ChainstateManager& chainman = EnsureChainman(node); - CChain chain; CBlockIndex* tip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); CHECK_NONFATAL(tip != nullptr); - chain.SetTip(*tip); try { - chainlock::CoinbaseChainLockReader reader(chain); + chainlock::CoinbaseChainLockReader reader(tip); const auto entry = reader.Find(height, height); if (!entry) throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Chainlock not found for height"); { @@ -1481,11 +1499,11 @@ static RPCHelpMan getquorumproofchain() (quorumText.empty() != (type == 0)) || (!quorumText.empty() && (quorumText.size() != 64 || !IsHex(quorumText)))) { throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid proof request"); } - CChain chain; const CBlockIndex* checkpoint; CBlockIndex* tip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); CHECK_NONFATAL(tip != nullptr); - chain.SetTip(*tip); + const auto chain_snapshot = GetProofChainSnapshot(tip); + const CChain& chain = *chain_snapshot; { LOCK(cs_main); checkpoint = chainman.m_blockman.LookupBlockIndex(anchorHash); From 569dbb0364947ac692adcc9d728405116d4f5746 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Sep 2026 15:09:24 -0500 Subject: [PATCH 05/10] fix(llmq): validate proof commitments independently of local network --- src/llmq/quorumproofs.cpp | 37 +++++++++++++++++--------------- src/test/quorum_proofs_tests.cpp | 7 +++++- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index bdc8bef7dee6..ddff0109b511 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -126,26 +126,29 @@ template static std::vector ConsensusBytes(const T& return {UCharCast(out.data()), UCharCast(out.data()) + out.size()}; } -static CFinalCommitment ParseCommitment(const std::vector& bytes) +static CFinalCommitment ParseCommitment(const std::vector& bytes, Consensus::LLMQType expectedType) { Require(bytes.size() <= 1024, "commitment size"); static ProofCache cache; const auto key = Hash(bytes); CFinalCommitment commitment; - if (cache.Get(key, commitment)) return commitment; - CDataStream in(bytes, SER_NETWORK, PROTOCOL_VERSION); - in >> commitment; - Require(in.empty() && (commitment.nVersion == 3 || commitment.nVersion == 4) && - !commitment.IsNull() && commitment.quorumPublicKey.IsValid() && - !commitment.quorumHash.IsNull(), "invalid Basic BLS commitment"); - const auto params = Params().GetLLMQ(commitment.llmqType); - Require(params.has_value(), "unsupported quorum type"); - Require((commitment.nVersion == 4) == (int(commitment.llmqType) == 5) && - commitment.quorumIndex >= 0 && commitment.quorumIndex < 32 && - commitment.signers.size() == size_t(params->size) && commitment.validMembers.size() == size_t(params->size) && - commitment.CountSigners() >= params->threshold && commitment.CountValidMembers() >= params->threshold && - ConsensusBytes(commitment) == bytes, "noncanonical or undersized commitment"); - cache.Insert(key, commitment); + if (!cache.Get(key, commitment)) { + CDataStream in(bytes, SER_NETWORK, PROTOCOL_VERSION); + in >> commitment; + Require(in.empty() && (commitment.nVersion == 3 || commitment.nVersion == 4) && + !commitment.IsNull() && commitment.quorumPublicKey.IsValid() && + !commitment.quorumHash.IsNull(), "invalid Basic BLS commitment"); + const auto params = std::find_if(Consensus::available_llmqs.begin(), Consensus::available_llmqs.end(), + [&](const Consensus::LLMQParams& item) { return item.type == commitment.llmqType; }); + Require(params != Consensus::available_llmqs.end(), "unsupported quorum type"); + Require((commitment.nVersion == 4) == (int(commitment.llmqType) == 5) && + commitment.quorumIndex >= 0 && commitment.quorumIndex < 32 && + commitment.signers.size() == size_t(params->size) && commitment.validMembers.size() == size_t(params->size) && + commitment.CountSigners() >= params->threshold && commitment.CountValidMembers() >= params->threshold && + ConsensusBytes(commitment) == bytes, "noncanonical or undersized commitment"); + cache.Insert(key, commitment); + } + Require(commitment.llmqType == expectedType, "unexpected quorum type"); return commitment; } @@ -305,7 +308,7 @@ ProofState QuorumProofChain::Verify(const ProofState& trusted) const // The application fixes the network and initial roots; the relay cannot choose them. Require(anchor.network <= 1, "unsupported proof network"); const auto kind = anchor.network == 0 ? Consensus::LLMQType::LLMQ_400_60 : Consensus::LLMQType::LLMQ_50_60; - auto signer = ParseCommitment(seed); + auto signer = ParseCommitment(seed, kind); Require(seedPath.Verify(Hash(seed), anchor.quorumRoot), "seed membership"); Require(links.size() < MAX_PROOF_CERTIFICATES, "certificate limit"); uint32_t height = anchor.height; @@ -325,7 +328,7 @@ ProofState QuorumProofChain::Verify(const ProofState& trusted) const tx->vin.empty() && tx->vout.empty() && tx->nLockTime == 0, "quorum transaction envelope"); auto payload = GetTxPayload(*tx); Require(payload && payload->nVersion == 1 && payload->nHeight == link.certificate.height - link.ancestors.size(), "quorum mining height"); - auto next = ParseCommitment(ConsensusBytes(payload->commitment)); + auto next = ParseCommitment(ConsensusBytes(payload->commitment), kind); Require(next.quorumHash != signer.quorumHash, "redundant signer"); signer = std::move(next); height = link.certificate.height; diff --git a/src/test/quorum_proofs_tests.cpp b/src/test/quorum_proofs_tests.cpp index aac2b0a3321b..e6b3ff2089a3 100644 --- a/src/test/quorum_proofs_tests.cpp +++ b/src/test/quorum_proofs_tests.cpp @@ -1,6 +1,7 @@ // Copyright (c) 2025-2026 The Dash Core developers // Distributed under the MIT software license, see the accompanying file COPYING. #include +#include #include #include #include @@ -81,7 +82,11 @@ static const char* TESTNET_PROOF = "0400000002b3336e59b1bc4bdccc3397c24a85c882184d2fb8d6f0a563d19100cadc53bdeb4d864dd73362cbcda1ec96a3f3" "3ad9678edcd1ff85bb3f2581b8d54d57685615" ; -BOOST_FIXTURE_TEST_SUITE(quorum_proofs_tests, BasicTestingSetup) +struct QuorumProofsRegtestSetup : BasicTestingSetup { + QuorumProofsRegtestSetup() : BasicTestingSetup(CBaseChainParams::REGTEST) {} +}; + +BOOST_FIXTURE_TEST_SUITE(quorum_proofs_tests, QuorumProofsRegtestSetup) BOOST_AUTO_TEST_CASE(real_testnet_wire_and_crypto) { auto bytes = ParseHex(TESTNET_PROOF); auto proof = llmq::QuorumProofChain::Decode(bytes); From aa92b2b2bf76d5b82c935f7b51f986424446604b Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Sep 2026 15:45:31 -0500 Subject: [PATCH 06/10] fix(rpc): preserve quorum proof hash arguments as strings --- src/rpc/client.cpp | 1 - test/functional/feature_quorum_proof_chain.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index ca34d732b346..277660fdac33 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -332,7 +332,6 @@ static const CRPCConvertParam vRPCConvertParams[] = { "protx update_service_evo", 9, "submit" }, { "getchainlockbyheight", 0, "height" }, { "getquorumproofchain", 1, "height" }, - { "getquorumproofchain", 2, "quorum_hash" }, { "getquorumproofchain", 3, "llmq_type" }, { "getquorumproofchain", 4, "node_count" }, { "quorum dkgsimerror", 2, "rate" }, diff --git a/test/functional/feature_quorum_proof_chain.py b/test/functional/feature_quorum_proof_chain.py index 468513ca0d0f..f53dad5bb778 100755 --- a/test/functional/feature_quorum_proof_chain.py +++ b/test/functional/feature_quorum_proof_chain.py @@ -25,7 +25,10 @@ def run_test(self): assert_raises_rpc_error(-8, "height must be non-negative", node.cli.getchainlockbyheight, -1) assert_raises_rpc_error(-8, "height must be non-negative", node.cli.getchainlockbyheight, height=-1) assert_raises_rpc_error(-1, "No archived certificate", node.cli.getquorumproofchain, - checkpoint_hash=node.getbestblockhash(), height=0, llmq_type=0, node_count=4) + checkpoint_hash=node.getbestblockhash(), height=0, quorum_hash="ab" * 32, + llmq_type=106, node_count=4) + assert_raises_rpc_error(-1, "No archived certificate", node.cli.getquorumproofchain, + node.getbestblockhash(), 0, "ab" * 32, 106) assert_equal(node.cli.verifyquorumproofchain(checkpoint=anchor, proof_hex=proof, minimum_height=0), result) # An unavailable archive request must not prevent independent verification From 67868ec9ffea515a0e872d1f759c1a0c207f0ff4 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Sep 2026 21:53:41 -0500 Subject: [PATCH 07/10] perf(chainlock): search monotonic historical certificates Use exponential bracketing and binary search over the certified-height ordering enforced by CheckCbTxBestChainlock. Preserve the request-local cache, disk-read budget and explicit unavailable-data errors. Keep null carriers only before the first certificate in the fixture, and cover a repeated-certificate span longer than the old linear scan budget. --- src/chainlock/clsig.cpp | 35 ++++++++++++++++++++----------- src/test/llmq_chainlock_tests.cpp | 20 +++++++++++++----- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/src/chainlock/clsig.cpp b/src/chainlock/clsig.cpp index 90bd81be27d2..4977ba9567bf 100644 --- a/src/chainlock/clsig.cpp +++ b/src/chainlock/clsig.cpp @@ -67,20 +67,31 @@ std::optional CoinbaseChainLockReader::Find(int minimum_heigh const int first_carrier = std::max(minimum_height + 1, Params().GetConsensus().V20Height); if (first_carrier > tip_height) return std::nullopt; - // Empty carriers are possible, so the predicate used by a binary search is - // not monotonic. Scan the bounded carrier range instead; Read() keeps the - // request-local disk-read budget and cache. Valid certificates are ordered - // by signed height, so a certificate above the requested maximum ends the - // search. - for (int carrier_height = first_carrier;; ++carrier_height) { - auto entry = Read(carrier_height); - if (entry) { - if (entry->clsig.getHeight() > maximum_height) return std::nullopt; - if (entry->clsig.getHeight() >= minimum_height) return entry; + // CheckCbTxBestChainlock enforces nondecreasing certified heights and + // forbids null signatures after the first certificate on a validated chain. + auto below_minimum = [&](int height) { + const auto entry = Read(height); + return !entry || entry->clsig.getHeight() < minimum_height; + }; + int low = first_carrier; + int high = first_carrier; + int64_t step = 1; + while (below_minimum(high)) { + if (high == tip_height) return std::nullopt; + low = high + 1; + high = int(std::min(tip_height, int64_t(high) + step)); + step *= 2; + } + while (low < high) { + const int middle = low + (high - low) / 2; + if (below_minimum(middle)) { + low = middle + 1; + } else { + high = middle; } - if (carrier_height == tip_height) break; } - return std::nullopt; + const auto entry = Read(low); + return entry && entry->clsig.getHeight() <= maximum_height ? entry : std::nullopt; } uint256 GenSigRequestId(const int32_t nHeight) diff --git a/src/test/llmq_chainlock_tests.cpp b/src/test/llmq_chainlock_tests.cpp index 58343ea9bcde..d6cdec5acfaf 100644 --- a/src/test/llmq_chainlock_tests.cpp +++ b/src/test/llmq_chainlock_tests.cpp @@ -54,18 +54,20 @@ BOOST_FIXTURE_TEST_CASE(historical_coinbase_lookup_from_disk, RegTestingSetup) std::deque hashes; std::deque indexes; CChain chain; - for (int height = 0; height <= activation + 128; ++height) { + for (int height = 0; height <= activation + 17000; ++height) { CCbTx payload; payload.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; payload.nHeight = height; - if (height == activation + 20 || height == activation + 80 || height == activation + 110) { + if (height >= activation + 20) { int certified; - if (height == activation + 20) { + if (height < activation + 80) { certified = activation + 1; - } else if (height == activation + 80) { + } else if (height < activation + 110) { certified = activation + 65; - } else { + } else if (height < activation + 17000) { certified = activation + 99; + } else { + certified = activation + 16999; } payload.bestCLSignature = signature; payload.bestCLHeightDiff = height - certified - 1; @@ -126,6 +128,14 @@ BOOST_FIXTURE_TEST_CASE(historical_coinbase_lookup_from_disk, RegTestingSetup) BOOST_CHECK_EQUAL(entry->carrier->nHeight, carrier); BOOST_CHECK(!reader.Find(minimum, expected - 1)); } + // A repeated certificate spanning more than the request's disk-read budget + // must still allow finding the next certificate (or reporting its absence). + chainlock::CoinbaseChainLockReader long_gap(chain); + const auto late = long_gap.Find(activation + 100, activation + 16999); + BOOST_REQUIRE(late); + BOOST_CHECK_EQUAL(late->carrier->nHeight, activation + 17000); + BOOST_CHECK_EQUAL(late->clsig.getHeight(), activation + 16999); + BOOST_CHECK(!long_gap.Find(activation + 100, activation + 16998)); // A new request on a shorter chain must not reuse the old request's cache. CChain shorter; shorter.SetTip(*chain[activation + 66]); From 8f7d38c4025908141699f22c95372d54dcbef4fa Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 15 Sep 2026 21:54:29 -0500 Subject: [PATCH 08/10] fix(rpc): retry proof targets across quorum retirement A checkpoint can retire a quorum that signing selection still uses for the next seven blocks. Return an explicit unavailable route from the builder for this case and try later certificates inside the original bounded target window. Keep missing data and other construction errors fatal, and preserve latest-certificate semantics for an omitted height. Add a disk-backed generation regression exercising the RPC, signing offset, later-target verification, exhausted history and unavailable checkpoint data. --- src/llmq/quorumproofs.cpp | 6 +- src/llmq/quorumproofs.h | 3 +- src/rpc/quorums.cpp | 74 ++++++------ src/test/quorum_proofs_tests.cpp | 186 +++++++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 34 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index ddff0109b511..a965820d30e3 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -462,7 +462,8 @@ static const CBlockIndex* MinedCommitmentBlock(const CQuorumBlockProcessor& proc return mined; } -QuorumProofChain QuorumProofBuilder::Build(const CBlockIndex* checkpoint, const chainlock::ChainLockSig& target) const +std::optional QuorumProofBuilder::Build(const CBlockIndex* checkpoint, + const chainlock::ChainLockSig& target) const { const auto& chain = m_chain; const auto& blocks = m_chainman.m_blockman; @@ -493,7 +494,8 @@ QuorumProofChain QuorumProofBuilder::Build(const CBlockIndex* checkpoint, const Require(proof.links.size() < MAX_PROOF_CERTIFICATES - 1, "certificate budget exhausted"); Require(!ShutdownRequested(), "proof construction interrupted"); const auto* mined = MinedCommitmentBlock(m_quorum_block_processor, chain, blocks, kind, needed->quorumHash); - Require(mined && mined->nHeight > checkpoint->nHeight, "no bridge to snapshot"); + Require(mined != nullptr, "mining block unavailable"); + if (mined->nHeight <= checkpoint->nHeight) return std::nullopt; CBlock block; Require(node::ReadBlockFromDisk(block, mined, Params().GetConsensus()), "mining block unavailable"); std::optional mining; diff --git a/src/llmq/quorumproofs.h b/src/llmq/quorumproofs.h index eeaa974e69f5..f68129a8d03d 100644 --- a/src/llmq/quorumproofs.h +++ b/src/llmq/quorumproofs.h @@ -77,7 +77,8 @@ class QuorumProofBuilder } std::vector ActiveCommitments(const CBlockIndex* index) const; static ProofState StateAt(const CBlockIndex* index); - QuorumProofChain Build(const CBlockIndex* checkpoint, const chainlock::ChainLockSig& target) const; + /** No value when the target requires a retired checkpoint quorum; other failures throw. */ + std::optional Build(const CBlockIndex* checkpoint, const chainlock::ChainLockSig& target) const; }; } // namespace llmq #endif // BITCOIN_LLMQ_QUORUMPROOFS_H diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 1337832f7c5d..7ebdae59dcfc 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -1513,37 +1513,49 @@ static RPCHelpMan getquorumproofchain() try { chainlock::CoinbaseChainLockReader reader(chain); const int64_t start = std::max(minimum, int64_t(checkpoint->nHeight) + 1); - std::optional target_chainlock; - if (minimum == 0) { - target_chainlock = reader.Read(chain.Height()); - } else if (start <= chain.Height()) { - target_chainlock = reader.Find(int(start), int(std::min(chain.Height(), - start + int64_t(llmq::MAX_PROOF_HEADERS)))); - } - chainlock::ChainLockSig target_signature; + if (start > chain.Height()) throw std::runtime_error("No archived certificate within search budget"); + const int maximum = int(std::min(chain.Height(), start + int64_t(llmq::MAX_PROOF_HEADERS))); const CBlockIndex* target_guard{nullptr}; - if (target_chainlock) { - target_signature = target_chainlock->clsig; - target_guard = target_chainlock->carrier; - } - // Platform can already reference a tip ChainLock before another - // block embeds it. The existing manager supplies the same final - // certificate; historical handoffs still come from disk. - if (minimum == 0 || !target_chainlock) { - const auto live = CHECK_NONFATAL(node.chainlocks)->GetBestChainLock(); - const auto* live_index = chain[live.getHeight()]; - if (live_index && live_index->GetBlockHash() == live.getBlockHash() && live.getHeight() >= start && - live.getHeight() > target_signature.getHeight() && - (minimum == 0 || int64_t(live.getHeight()) <= start + int64_t(llmq::MAX_PROOF_HEADERS))) { - target_signature = live; - target_guard = live_index; + const CBlockIndex* target{nullptr}; + llmq::QuorumProofBuilder builder(*ctx.quorum_block_processor, *ctx.qman, chain, chainman, reader); + std::optional proof; + // At a quorum-mining boundary, the signing offset can select a + // retired quorum absent from the checkpoint root. Height is a + // minimum, so try later certificates within the original budget. + for (int64_t next = start; next <= maximum;) { + target_guard = nullptr; + std::optional target_chainlock; + if (minimum == 0) { + target_chainlock = reader.Read(chain.Height()); + } else { + target_chainlock = reader.Find(int(next), maximum); + } + chainlock::ChainLockSig target_signature; + if (target_chainlock) { + target_signature = target_chainlock->clsig; + target_guard = target_chainlock->carrier; } + // Platform can already reference a tip ChainLock before another + // block embeds it. The existing manager supplies the same final + // certificate; historical handoffs still come from disk. + if (minimum == 0 || !target_chainlock) { + const auto live = CHECK_NONFATAL(node.chainlocks)->GetBestChainLock(); + const auto* live_index = chain[live.getHeight()]; + if (live_index && live_index->GetBlockHash() == live.getBlockHash() && + live.getHeight() >= next && live.getHeight() > target_signature.getHeight() && + (minimum == 0 || live.getHeight() <= maximum)) { + target_signature = live; + target_guard = live_index; + } + } + if (!target_guard || target_signature.getHeight() <= checkpoint->nHeight) + throw std::runtime_error("No archived certificate within search budget"); + target = chain[target_signature.getHeight()]; + proof = builder.Build(checkpoint, target_signature); + if (proof || minimum == 0) break; + next = int64_t(target_signature.getHeight()) + 1; } - if (!target_guard || target_signature.getHeight() <= checkpoint->nHeight) - throw std::runtime_error("No archived certificate within search budget"); - const auto* target = chain[target_signature.getHeight()]; - llmq::QuorumProofBuilder builder(*ctx.quorum_block_processor, *ctx.qman, chain, chainman, reader); - auto proof = builder.Build(checkpoint, target_signature); + if (!proof) throw std::runtime_error("No bridge to snapshot within search budget"); std::vector records; if (!quorumText.empty()) { const auto hash = uint256S(quorumText); @@ -1587,9 +1599,9 @@ static RPCHelpMan getquorumproofchain() throw std::runtime_error("Chain changed during proof construction; retry"); } UniValue result(UniValue::VOBJ); - result.pushKV("proof_hex", HexStr(proof.Encode())); - result.pushKV("bootstrap_hex", records.empty() ? "" : HexStr(llmq::EncodeBootstrap(proof, records))); - result.pushKV("target", proof.Verify(proof.anchor).ToJson()); + result.pushKV("proof_hex", HexStr(proof->Encode())); + result.pushKV("bootstrap_hex", records.empty() ? "" : HexStr(llmq::EncodeBootstrap(*proof, records))); + result.pushKV("target", proof->Verify(proof->anchor).ToJson()); return result; } catch (const std::exception& e) { throw JSONRPCError(RPC_MISC_ERROR, e.what()); diff --git a/src/test/quorum_proofs_tests.cpp b/src/test/quorum_proofs_tests.cpp index e6b3ff2089a3..964be5592d00 100644 --- a/src/test/quorum_proofs_tests.cpp +++ b/src/test/quorum_proofs_tests.cpp @@ -1,12 +1,26 @@ // Copyright (c) 2025-2026 The Dash Core developers // Distributed under the MIT software license, see the accompanying file COPYING. #include +#include #include +#include +#include +#include +#include +#include #include #include +#include +#include #include +#include +#include +#include +#include +#include #include #include +#include // Actual testnet archive proof, heights 1548500 -> 1549547. Independently // verified by the Rust BLS/X11 implementation; includes 0, 1 and 4 ancestors. @@ -86,7 +100,179 @@ struct QuorumProofsRegtestSetup : BasicTestingSetup { QuorumProofsRegtestSetup() : BasicTestingSetup(CBaseChainParams::REGTEST) {} }; +// Use a single active quorum to deterministically exercise retirement at a +// mining boundary. The production selector, disk reader, builder and RPC run +// unchanged; only PoW difficulty and the active-set size are reduced. +struct QuorumProofGenerationSetup : TestingSetup { + Consensus::Params saved_consensus; + CBlockIndex* saved_tip; + int checkpoint_height; + std::vector commitments; + std::vector keys; + + QuorumProofGenerationSetup() : + TestingSetup(CBaseChainParams::TESTNET), + saved_consensus(Params().GetConsensus()), + saved_tip(WITH_LOCK(cs_main, return m_node.chainman->ActiveChain().Tip())) + { + auto& consensus = const_cast(Params().GetConsensus()); + consensus.powLimit = uint256S("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); + for (auto& params : consensus.llmqs) { + if (params.type == consensus.llmqTypeChainLocks) params.signingActiveQuorumCount = 1; + } + const auto params = *Params().GetLLMQ(consensus.llmqTypeChainLocks); + checkpoint_height = ((consensus.V20Height + 2 * params.dkgInterval) / params.dkgInterval) * params.dkgInterval + + params.dkgMiningWindowStart; + } + + ~QuorumProofGenerationSetup() + { + WITH_LOCK(cs_main, m_node.chainman->ActiveChain().SetTip(*saved_tip)); + const_cast(Params().GetConsensus()) = saved_consensus; + } + + void CreateHistory() + { + const auto& consensus = Params().GetConsensus(); + const auto params = *Params().GetLLMQ(consensus.llmqTypeChainLocks); + auto& chainman = *m_node.chainman; + auto& chain = *WITH_LOCK(cs_main, return &chainman.ActiveChain()); + int first_height = checkpoint_height; + for (const auto& quorum_params : consensus.llmqs) { + first_height = std::min(first_height, checkpoint_height - 2 * quorum_params.dkgInterval); + } + CBlockIndex* previous{nullptr}; + for (int height = first_height; height <= checkpoint_height + llmq::SIGN_HEIGHT_OFFSET + 1; ++height) { + CCbTx payload; + payload.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; + payload.nHeight = height; + if (!commitments.empty()) payload.merkleRootQuorums = SerializeHash(commitments.back()); + if (height > checkpoint_height + 1) { + const auto signer = llmq::SelectCommitmentForSigning(params, chain, *m_node.llmq_ctx->qman, + chainlock::GenSigRequestId(height - 1), height - 1, + llmq::SIGN_HEIGHT_OFFSET); + BOOST_REQUIRE(signer); + const size_t key = signer->quorumHash == commitments.front().quorumHash ? 0 : 1; + payload.bestCLSignature = keys[key].Sign(llmq::SignHash{params.type, signer->quorumHash, + chainlock::GenSigRequestId(height - 1), + previous->GetBlockHash()} + .Get(), + false); + } + const bool mining = height == checkpoint_height - params.dkgInterval || height == checkpoint_height; + if (mining) { + keys.emplace_back(); + keys.back().MakeNewKey(); + llmq::CFinalCommitment commitment; + commitment.nVersion = llmq::CFinalCommitment::BASIC_BLS_NON_INDEXED_QUORUM_VERSION; + commitment.llmqType = params.type; + commitment.quorumHash = previous->GetAncestor(height - params.dkgMiningWindowStart)->GetBlockHash(); + commitment.quorumPublicKey = keys.back().GetPublicKey(); + commitment.signers.assign(params.size, true); + commitment.validMembers.assign(params.size, true); + commitments.push_back(commitment); + payload.merkleRootQuorums = SerializeHash(commitment); + } + CMutableTransaction coinbase; + coinbase.nVersion = 3; + coinbase.nType = TRANSACTION_COINBASE; + coinbase.vin.resize(1); + coinbase.vin[0].scriptSig = CScript() << height << OP_0; + coinbase.vout.emplace_back(0, CScript() << OP_TRUE); + SetTxPayload(coinbase, payload); + CBlock block; + block.nVersion = 1; + block.hashPrevBlock = previous ? previous->GetBlockHash() : uint256{}; + block.nBits = 0x207fffff; + block.nTime = Params().GenesisBlock().nTime + height; + block.vtx = {MakeTransactionRef(coinbase)}; + if (mining) { + CMutableTransaction tx; + tx.nVersion = 3; + tx.nType = TRANSACTION_QUORUM_COMMITMENT; + llmq::CFinalCommitmentTxPayload qc; + qc.nHeight = height; + qc.commitment = commitments.back(); + SetTxPayload(tx, qc); + block.vtx.push_back(MakeTransactionRef(tx)); + } + block.hashMerkleRoot = BlockMerkleRoot(block); + while (!CheckProofOfWork(block.GetHash(), block.nBits, consensus)) + ++block.nNonce; + const auto pos = chainman.m_blockman.SaveBlockToDisk(block, height, nullptr); + BOOST_REQUIRE(!pos.IsNull()); + LOCK(cs_main); + auto* index = chainman.m_blockman.InsertBlockIndex(block.GetHash()); + index->nVersion = block.nVersion; + index->hashMerkleRoot = block.hashMerkleRoot; + index->nTime = block.nTime; + index->nBits = block.nBits; + index->nNonce = block.nNonce; + index->pprev = previous; + index->nHeight = height; + index->nFile = pos.nFile; + index->nDataPos = pos.nPos; + index->nStatus = BLOCK_HAVE_DATA; + chain.SetTip(*index); + previous = index; + if (mining) { + const auto& qc = commitments.back(); + m_node.evodb->Write(std::make_pair(std::string{"q_mc"}, std::make_pair(params.type, qc.quorumHash)), + std::make_pair(qc, block.GetHash())); + m_node.evodb->Write(std::make_tuple(std::string{"q_mcih"}, params.type, + htobe32_internal(UINT32_MAX - height)), + height - params.dkgMiningWindowStart); + } + } + } + + UniValue Generate(int minimum) + { + JSONRPCRequest request; + request.context = CoreContext{m_node}; + request.strMethod = "getquorumproofchain"; + request.params = UniValue{UniValue::VARR}; + request.params.push_back( + WITH_LOCK(cs_main, return m_node.chainman->ActiveChain()[checkpoint_height]->GetBlockHash().ToString())); + request.params.push_back(minimum); + if (RPCIsInWarmup(nullptr)) SetRPCWarmupFinished(); + return tableRPC.execute(request); + } +}; + BOOST_FIXTURE_TEST_SUITE(quorum_proofs_tests, QuorumProofsRegtestSetup) +BOOST_FIXTURE_TEST_CASE(generation_retries_retired_checkpoint_signer, QuorumProofGenerationSetup) +{ + CreateHistory(); + auto& chain = *WITH_LOCK(cs_main, return &m_node.chainman->ActiveChain()); + const auto* checkpoint = chain[checkpoint_height]; + chainlock::CoinbaseChainLockReader reader(chain); + llmq::QuorumProofBuilder builder(*m_node.llmq_ctx->quorum_block_processor, *m_node.llmq_ctx->qman, chain, + *m_node.chainman, reader); + const auto first = reader.Find(checkpoint_height + 1, chain.Height()); + BOOST_REQUIRE(first); + BOOST_CHECK_EQUAL(first->clsig.getHeight(), checkpoint_height + 1); + BOOST_CHECK(!builder.Build(checkpoint, first->clsig)); + + const auto result = Generate(checkpoint_height + 1); + const auto proof = llmq::QuorumProofChain::Decode(ParseHex(result["proof_hex"].get_str())); + BOOST_CHECK_EQUAL(proof.Verify(llmq::QuorumProofBuilder::StateAt(checkpoint)).height, + checkpoint_height + llmq::SIGN_HEIGHT_OFFSET); + BOOST_CHECK(proof.links.empty()); + + // A later certificate is required; exhaustion must not return the unbound target. + auto* tip = chain.Tip(); + WITH_LOCK(cs_main, chain.SetTip(*chain[checkpoint_height + 2])); + BOOST_CHECK_THROW(Generate(checkpoint_height + 1), UniValue); + BOOST_CHECK_THROW(Generate(0), UniValue); + WITH_LOCK(cs_main, chain.SetTip(*tip)); + + // Missing history is a hard error, not a reason to skip to another target. + WITH_LOCK(cs_main, chain[checkpoint_height]->nStatus &= ~BLOCK_HAVE_DATA); + BOOST_CHECK_EXCEPTION(Generate(checkpoint_height + 1), UniValue, [](const UniValue& error) { + return error["message"].get_str().find("historical block unavailable") != std::string::npos; + }); +} BOOST_AUTO_TEST_CASE(real_testnet_wire_and_crypto) { auto bytes = ParseHex(TESTNET_PROOF); auto proof = llmq::QuorumProofChain::Decode(bytes); From 5885d7ba12348675e29dbd5fad1a89b820282f97 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 16 Sep 2026 11:37:21 -0500 Subject: [PATCH 09/10] fix: scope proof chain snapshot cache to chainstate --- src/rpc/quorums.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 7ebdae59dcfc..5148a79713d0 100644 --- a/src/rpc/quorums.cpp +++ b/src/rpc/quorums.cpp @@ -51,15 +51,17 @@ using node::NodeContext; namespace { std::mutex g_proof_chain_mutex; std::shared_ptr g_proof_chain_snapshot; +const ChainstateManager* g_proof_chain_owner{nullptr}; const CBlockIndex* g_proof_chain_tip{nullptr}; -std::shared_ptr GetProofChainSnapshot(CBlockIndex* tip) +std::shared_ptr GetProofChainSnapshot(const ChainstateManager& chainman, CBlockIndex* tip) { std::lock_guard lock(g_proof_chain_mutex); - if (!g_proof_chain_snapshot || g_proof_chain_tip != tip) { + if (!g_proof_chain_snapshot || g_proof_chain_owner != &chainman || g_proof_chain_tip != tip) { auto snapshot = std::make_shared(); snapshot->SetTip(*tip); g_proof_chain_snapshot = std::move(snapshot); + g_proof_chain_owner = &chainman; g_proof_chain_tip = tip; } return g_proof_chain_snapshot; @@ -1502,7 +1504,7 @@ static RPCHelpMan getquorumproofchain() const CBlockIndex* checkpoint; CBlockIndex* tip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); CHECK_NONFATAL(tip != nullptr); - const auto chain_snapshot = GetProofChainSnapshot(tip); + const auto chain_snapshot = GetProofChainSnapshot(chainman, tip); const CChain& chain = *chain_snapshot; { LOCK(cs_main); From 725f7221bfa23e8ea47c03e2d25a2f67b8429520 Mon Sep 17 00:00:00 2001 From: UdjinM6 Date: Fri, 18 Sep 2026 17:59:06 +0300 Subject: [PATCH 10/10] fix(llmq): accept every consensus-valid proof transaction shape QuorumProofChain::Verify required a mined quorum commitment to have no inputs, no outputs, a zero lock time and exactly transaction version 3, and Coinbase() capped the coinbase at 4,096 outputs. Consensus requires none of this: CheckTransaction only allows rather than requires empty vin/vout for TRANSACTION_QUORUM_COMMITMENT, IsSpecialTxVersion() accepts any version at or above 3, nothing reads a mined commitment's lock time, and a coinbase is bounded only by MAX_STANDARD_TX_SIZE. A miner running custom software could therefore mine a consensus-valid commitment that no proof can carry, at no cost and indistinguishably from an honest block. A quorum's commitment is mined exactly once and a second non-null commitment for the same quorum is rejected, so that quorum's key could never be proven again: every handoff that needs it fails for getquorumproofchain and for independent verifiers alike. The transaction is already bound to the ChainLock-signed header by its Merkle path, so these checks add no security. Keep the payload type, version and height checks, the coinbase scriptSig bounds consensus does enforce, and the 100,000 byte limit, which matches MAX_STANDARD_TX_SIZE. DIP #175 steps 5 and 7 state the same restrictions and need the matching update so independent verifiers stay in agreement with Core. Co-Authored-By: Claude Opus 5 --- src/llmq/quorumproofs.cpp | 10 +++--- src/test/quorum_proofs_tests.cpp | 60 ++++++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/llmq/quorumproofs.cpp b/src/llmq/quorumproofs.cpp index a965820d30e3..0de6dda7b11f 100644 --- a/src/llmq/quorumproofs.cpp +++ b/src/llmq/quorumproofs.cpp @@ -166,9 +166,9 @@ static CCbTx Coinbase(const ProofTransaction& proof, const ProofCertificate& cer { Require(proof.path.index == 0 && proof.Verify(cert.header), "coinbase inclusion"); const auto tx = ParseTransaction(proof.transaction); - Require(tx->IsCoinBase() && tx->nVersion == 3 && tx->nType == TRANSACTION_COINBASE && - !tx->vin[0].scriptSig.empty() && tx->vin[0].scriptSig.size() <= 100 && - !tx->vout.empty() && tx->vout.size() <= 4096, "coinbase envelope"); + Require(tx->IsCoinBase() && tx->IsSpecialTxVersion() && tx->nType == TRANSACTION_COINBASE && + !tx->vin[0].scriptSig.empty() && tx->vin[0].scriptSig.size() <= 100 && !tx->vout.empty(), + "coinbase envelope"); auto payload = GetTxPayload(*tx); Require(payload && payload->nVersion >= CCbTx::Version::CLSIG_AND_BALANCE && payload->nHeight == int64_t(cert.height) && payload->bestCLHeightDiff < cert.height && @@ -324,8 +324,8 @@ ProofState QuorumProofChain::Verify(const ProofState& trusted) const } Require(link.mining.path.index != 0 && link.mining.Verify(*descendant), "mining inclusion"); auto tx = ParseTransaction(link.mining.transaction); - Require(tx->nVersion == 3 && tx->nType == TRANSACTION_QUORUM_COMMITMENT && - tx->vin.empty() && tx->vout.empty() && tx->nLockTime == 0, "quorum transaction envelope"); + Require(tx->IsSpecialTxVersion() && tx->nType == TRANSACTION_QUORUM_COMMITMENT, + "quorum transaction envelope"); auto payload = GetTxPayload(*tx); Require(payload && payload->nVersion == 1 && payload->nHeight == link.certificate.height - link.ancestors.size(), "quorum mining height"); auto next = ParseCommitment(ConsensusBytes(payload->commitment), kind); diff --git a/src/test/quorum_proofs_tests.cpp b/src/test/quorum_proofs_tests.cpp index 964be5592d00..04d1e9455d45 100644 --- a/src/test/quorum_proofs_tests.cpp +++ b/src/test/quorum_proofs_tests.cpp @@ -1,6 +1,7 @@ // Copyright (c) 2025-2026 The Dash Core developers // Distributed under the MIT software license, see the accompanying file COPYING. #include +#include #include #include #include @@ -107,6 +108,8 @@ struct QuorumProofGenerationSetup : TestingSetup { Consensus::Params saved_consensus; CBlockIndex* saved_tip; int checkpoint_height; + int handoff_height{0}; + bool mine_nonstandard_handoff{false}; std::vector commitments; std::vector keys; @@ -141,8 +144,11 @@ struct QuorumProofGenerationSetup : TestingSetup { for (const auto& quorum_params : consensus.llmqs) { first_height = std::min(first_height, checkpoint_height - 2 * quorum_params.dkgInterval); } + handoff_height = checkpoint_height + params.dkgInterval; + const int last_height = (mine_nonstandard_handoff ? handoff_height : checkpoint_height) + + llmq::SIGN_HEIGHT_OFFSET + 1; CBlockIndex* previous{nullptr}; - for (int height = first_height; height <= checkpoint_height + llmq::SIGN_HEIGHT_OFFSET + 1; ++height) { + for (int height = first_height; height <= last_height; ++height) { CCbTx payload; payload.nVersion = CCbTx::Version::CLSIG_AND_BALANCE; payload.nHeight = height; @@ -152,14 +158,19 @@ struct QuorumProofGenerationSetup : TestingSetup { chainlock::GenSigRequestId(height - 1), height - 1, llmq::SIGN_HEIGHT_OFFSET); BOOST_REQUIRE(signer); - const size_t key = signer->quorumHash == commitments.front().quorumHash ? 0 : 1; + const auto match = std::find_if(commitments.begin(), commitments.end(), [&](const auto& entry) { + return entry.quorumHash == signer->quorumHash; + }); + BOOST_REQUIRE(match != commitments.end()); + const size_t key = size_t(match - commitments.begin()); payload.bestCLSignature = keys[key].Sign(llmq::SignHash{params.type, signer->quorumHash, chainlock::GenSigRequestId(height - 1), previous->GetBlockHash()} .Get(), false); } - const bool mining = height == checkpoint_height - params.dkgInterval || height == checkpoint_height; + const bool mining = height == checkpoint_height - params.dkgInterval || height == checkpoint_height || + (mine_nonstandard_handoff && height == handoff_height); if (mining) { keys.emplace_back(); keys.back().MakeNewKey(); @@ -179,6 +190,10 @@ struct QuorumProofGenerationSetup : TestingSetup { coinbase.vin.resize(1); coinbase.vin[0].scriptSig = CScript() << height << OP_0; coinbase.vout.emplace_back(0, CScript() << OP_TRUE); + if (mine_nonstandard_handoff && height > checkpoint_height) { + // Consensus bounds a coinbase by transaction size alone. + coinbase.vout.resize(4097, CTxOut(0, CScript() << OP_TRUE)); + } SetTxPayload(coinbase, payload); CBlock block; block.nVersion = 1; @@ -190,6 +205,15 @@ struct QuorumProofGenerationSetup : TestingSetup { CMutableTransaction tx; tx.nVersion = 3; tx.nType = TRANSACTION_QUORUM_COMMITMENT; + if (mine_nonstandard_handoff && height == handoff_height) { + // Consensus permits any special version here, allows rather + // than requires empty inputs and outputs, and never reads + // the lock time of a mined commitment. + tx.nVersion = 4; + tx.nLockTime = 1; + tx.vin.emplace_back(COutPoint(uint256::ONE, 0)); + tx.vout.emplace_back(0, CScript() << OP_TRUE); + } llmq::CFinalCommitmentTxPayload qc; qc.nHeight = height; qc.commitment = commitments.back(); @@ -273,6 +297,36 @@ BOOST_FIXTURE_TEST_CASE(generation_retries_retired_checkpoint_signer, QuorumProo return error["message"].get_str().find("historical block unavailable") != std::string::npos; }); } +BOOST_FIXTURE_TEST_CASE(consensus_valid_envelopes_are_provable, QuorumProofGenerationSetup) +{ + // A handoff must stay provable whatever shape the miner gave the mined + // commitment, otherwise one block mined by custom software permanently + // removes that quorum's key from every proof. + mine_nonstandard_handoff = true; + CreateHistory(); + auto& chain = *WITH_LOCK(cs_main, return &m_node.chainman->ActiveChain()); + const auto* checkpoint = chain[checkpoint_height]; + chainlock::CoinbaseChainLockReader reader(chain); + llmq::QuorumProofBuilder builder(*m_node.llmq_ctx->quorum_block_processor, *m_node.llmq_ctx->qman, chain, + *m_node.chainman, reader); + const auto entry = reader.Find(handoff_height + llmq::SIGN_HEIGHT_OFFSET, chain.Height()); + BOOST_REQUIRE(entry); + const auto proof = builder.Build(checkpoint, entry->clsig); + BOOST_REQUIRE(proof); + BOOST_REQUIRE_EQUAL(proof->links.size(), 1U); + + CDataStream stream(proof->links[0].mining.transaction, SER_NETWORK, PROTOCOL_VERSION); + CMutableTransaction mining; + stream >> mining; + BOOST_CHECK_EQUAL(mining.nVersion, 4); + BOOST_CHECK_EQUAL(mining.nLockTime, 1U); + BOOST_CHECK(!mining.vin.empty()); + BOOST_CHECK(!mining.vout.empty()); + + const auto state = proof->Verify(llmq::QuorumProofBuilder::StateAt(checkpoint)); + BOOST_CHECK_EQUAL(state.height, uint32_t(entry->clsig.getHeight())); + BOOST_CHECK(state.blockHash == proof->target.header.GetHash()); +} BOOST_AUTO_TEST_CASE(real_testnet_wire_and_crypto) { auto bytes = ParseHex(TESTNET_PROOF); auto proof = llmq::QuorumProofChain::Decode(bytes);