diff --git a/.gitignore b/.gitignore index eab9f3f83ec1..072915c7438c 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,4 @@ compile_commands.json # Linux perf profiling artifacts perf.data perf.data.old +activity.md 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/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 a34ea4b120a3..5732d83acd33 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/chainlock/clsig.cpp b/src/chainlock/clsig.cpp index 6ccdcaae443c..98fdb43befdd 100644 --- a/src/chainlock/clsig.cpp +++ b/src/chainlock/clsig.cpp @@ -4,14 +4,83 @@ #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->bestCLSignature.IsValid()) { + return m_cache.emplace(carrier_height, std::nullopt).first->second; + } + if (cb->bestCLHeightDiff >= uint32_t(carrier_height)) { + throw std::runtime_error("Invalid historical coinbase ChainLock height"); + } + const int height = carrier_height - int(cb->bestCLHeightDiff) - 1; + return m_cache + .emplace(carrier_height, + CoinbaseChainLock{ChainLockSig{height, m_chain[height]->GetBlockHash(), cb->bestCLSignature}, 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; + int low = std::max(minimum_height + 1, Params().GetConsensus().V20Height); + if (low > m_chain.Height()) return std::nullopt; + int high = low; + int64_t step = 1; + // Valid coinbases never move backwards in certified height. Exponential + // search finds a nearby carrier quickly, even after a long signing gap. + while (true) { + const auto entry = Read(high); + if (entry && entry->clsig.getHeight() >= minimum_height) break; + if (high == m_chain.Height()) return std::nullopt; + low = high + 1; + high = int(std::min(m_chain.Height(), int64_t(high) + step)); + step *= 2; + } + while (low < high) { + const int middle = low + (high - low) / 2; + const auto entry = Read(middle); + if (entry && entry->clsig.getHeight() >= minimum_height) + high = middle; + else + low = middle + 1; + } + auto entry = Read(low); + if (entry && entry->clsig.getHeight() <= maximum_height) return entry; + 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/llmq/quorumproofdata.h b/src/llmq/quorumproofdata.h new file mode 100644 index 000000000000..699d71e52ee5 --- /dev/null +++ b/src/llmq/quorumproofdata.h @@ -0,0 +1,29 @@ +// Copyright (c) 2025-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..48894debd65c --- /dev/null +++ b/src/llmq/quorumproofs.cpp @@ -0,0 +1,487 @@ +// 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 + +namespace llmq { +static constexpr std::array PROOF_MAGIC{'D', 'A', 'S', 'H', 'N', 'C', '0', '2'}; + +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"); + CDataStream in(bytes, SER_NETWORK, PROTOCOL_VERSION); + CFinalCommitment commitment; + in >> commitment; + Require(in.empty() && (commitment.nVersion == 3 || commitment.nVersion == 4) && + !commitment.IsNull() && commitment.quorumPublicKey.IsValid() && + !commitment.quorumHash.IsNull(), "invalid Basic BLS commitment"); + int size{0}, threshold{0}; + switch (int(commitment.llmqType)) { + case 1: size = 50; threshold = 30; break; + case 2: size = 400; threshold = 240; break; + case 3: size = 400; threshold = 340; break; + case 4: size = 100; threshold = 67; break; + case 5: size = 60; threshold = 45; break; + case 6: size = 25; threshold = 17; break; + default: throw std::runtime_error("unsupported quorum type"); + } + Require((commitment.nVersion == 4) == (int(commitment.llmqType) == 5) && + commitment.quorumIndex >= 0 && commitment.quorumIndex < 32 && + commitment.signers.size() == size_t(size) && commitment.validMembers.size() == size_t(size) && + commitment.CountSigners() >= threshold && commitment.CountValidMembers() >= threshold && + ConsensusBytes(commitment) == bytes, "noncanonical or undersized 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()}; + return signature.VerifyInsecure(signer.quorumPublicKey, hash.Get(), false); +} + +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) +{ + 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 CChain& chain, + const CQuorumManager& qman) +{ + const auto params = Params().GetLLMQ(Params().GetConsensus().llmqTypeChainLocks); + if (!params) return std::nullopt; + LOCK(cs_main); + return SelectCommitmentForSigning(*params, chain, qman, chainlock::GenSigRequestId(height), height, SIGN_HEIGHT_OFFSET); +} + +QuorumProofChain QuorumProofBuilder::Build(const CBlockIndex* checkpoint, const chainlock::CoinbaseChainLock& target) const +{ + const auto& chain = m_chain; + const auto& qman = m_qman; + const auto& blocks = m_blocks; + const auto* targetIndex = chain[target.clsig.getHeight()]; + Require(checkpoint && targetIndex && chain.Contains(checkpoint) && checkpoint->nHeight < targetIndex->nHeight, + "checkpoint/target not on chain"); + 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 = certificate(target); + auto needed = DetermineChainlockSigningCommitment(targetIndex->nHeight, chain, qman); + 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"); + auto [commitment, + minedHash] = WITH_LOCK(cs_main, return m_quorum_block_processor.GetMinedCommitment(kind, needed->quorumHash)); + const auto* mined = WITH_LOCK(cs_main, return blocks.LookupBlockIndex(minedHash)); + Require(!commitment.IsNull() && mined && chain.Contains(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, chain, qman); + if (!signer || signer->quorumHash == needed->quorumHash) continue; + auto [previousCommitment, + previousHash] = WITH_LOCK(cs_main, + return m_quorum_block_processor.GetMinedCommitment(kind, signer->quorumHash)); + const auto* previous = WITH_LOCK(cs_main, return blocks.LookupBlockIndex(previousHash)); + 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..321817cf7863 --- /dev/null +++ b/src/llmq/quorumproofs.h @@ -0,0 +1,82 @@ +// 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; +namespace node { class BlockManager; } +namespace chainlock { +class CoinbaseChainLockReader; +struct CoinbaseChainLock; +} // 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 node::BlockManager& m_blocks; + chainlock::CoinbaseChainLockReader& m_chainlocks; + static std::optional DetermineChainlockSigningCommitment(int32_t height, const CChain& chain, const CQuorumManager& qman); +public: + QuorumProofBuilder(const CQuorumBlockProcessor& processor, const CQuorumManager& qman, const CChain& chain, + const node::BlockManager& blocks, chainlock::CoinbaseChainLockReader& chainlocks) : + m_quorum_block_processor(processor), + m_qman(qman), + m_chain(chain), + m_blocks(blocks), + m_chainlocks(chainlocks) + { + } + std::vector ActiveCommitments(const CBlockIndex* index) const; + static ProofState StateAt(const CBlockIndex* index); + QuorumProofChain Build(const CBlockIndex* checkpoint, const chainlock::CoinbaseChainLock& target) const; +}; +} // namespace llmq +#endif // BITCOIN_LLMQ_QUORUMPROOFS_H diff --git a/src/llmq/quorumsman.cpp b/src/llmq/quorumsman.cpp index 6dd0fc53a733..8008f49b22d7 100644 --- a/src/llmq/quorumsman.cpp +++ b/src/llmq/quorumsman.cpp @@ -293,6 +293,114 @@ std::vector CQuorumManager::ScanQuorums(Consensus::LLMQType llmqTyp return {vecResultQuorums.begin(), vecResultQuorums.begin() + nResultEndIndex}; } +std::vector CQuorumManager::ScanCommitments(Consensus::LLMQType llmqType, size_t nCountRequested) const +{ + const CBlockIndex* pindex = WITH_LOCK(::cs_main, return m_chainman.ActiveTip()); + return ScanCommitments(llmqType, pindex, nCountRequested); +} + +std::vector CQuorumManager::ScanCommitments(Consensus::LLMQType llmqType, + gsl::not_null pindexStart, + size_t nCountRequested) const +{ + if (nCountRequested == 0 || !m_chainman.IsQuorumTypeEnabled(llmqType, pindexStart)) { + return {}; + } + + gsl::not_null pindexStore{pindexStart}; + const auto& llmq_params_opt = Params().GetLLMQ(llmqType); + assert(llmq_params_opt.has_value()); + + // Quorum sets can only change during the mining phase of DKG. + // Find the closest known block index. + const int quorumCycleStartHeight = pindexStart->nHeight - (pindexStart->nHeight % llmq_params_opt->dkgInterval); + const int quorumCycleMiningStartHeight = quorumCycleStartHeight + llmq_params_opt->dkgMiningWindowStart; + const int quorumCycleMiningEndHeight = quorumCycleStartHeight + llmq_params_opt->dkgMiningWindowEnd; + + if (pindexStart->nHeight < quorumCycleMiningStartHeight) { + // too early for this cycle, use the previous one + // bail out if it's below genesis block + if (quorumCycleMiningEndHeight < llmq_params_opt->dkgInterval) return {}; + pindexStore = pindexStart->GetAncestor(quorumCycleMiningEndHeight - llmq_params_opt->dkgInterval); + } else if (pindexStart->nHeight > quorumCycleMiningEndHeight) { + // we are past the mining phase of this cycle, use it + pindexStore = pindexStart->GetAncestor(quorumCycleMiningEndHeight); + } + // everything else is inside the mining phase of this cycle, no pindexStore adjustment needed + + gsl::not_null pIndexScanCommitments{pindexStore}; + size_t nScanCommitments{nCountRequested}; + std::vector vecResultCommitments; + + { + LOCK(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(); + // bail out if it's below genesis block + const CBlockIndex* pLastIndex = WITH_LOCK(::cs_main, return m_chainman.m_blockman.LookupBlockIndex(vecResultCommitments.back().quorumHash)); + if (!pLastIndex || pLastIndex->pprev == nullptr) return {}; + pIndexScanCommitments = pLastIndex->pprev; + } + } else { + // If there is nothing in cache request at least keepOldConnections because this gets cached then later + nScanCommitments = std::max(nCountRequested, static_cast(llmq_params_opt->keepOldConnections)); + } + } + + // Get the block indexes of the mined commitments to build the required quorums from + std::vector pQuorumBaseBlockIndexes{ llmq_params_opt->useRotation ? + quorumBlockProcessor.GetMinedCommitmentsIndexedUntilBlock(llmqType, pIndexScanCommitments, nScanCommitments) : + quorumBlockProcessor.GetMinedCommitmentsUntilBlock(llmqType, pIndexScanCommitments, nScanCommitments) + }; + vecResultCommitments.reserve(vecResultCommitments.size() + pQuorumBaseBlockIndexes.size()); + + for (auto& pQuorumBaseBlockIndex : pQuorumBaseBlockIndexes) { + assert(pQuorumBaseBlockIndex); + // We assume that every quorum asked for is available to us on hand, if this + // fails then we can assume that something has gone wrong and we should stop + // trying to process any further and return a blank. + auto [qc, _] = quorumBlockProcessor.GetMinedCommitment(llmqType, pQuorumBaseBlockIndex->GetBlockHash()); + if (qc.IsNull()) { + LogPrintf("%s: ERROR! Unexpected missing commitment with llmqType=%d, blockHash=%s\n", + __func__, 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) { @@ -703,6 +811,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) @@ -710,5 +880,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 f61f30b4c6b1..5a642f00e43f 100644 --- a/src/llmq/quorumsman.h +++ b/src/llmq/quorumsman.h @@ -87,6 +87,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); @@ -150,6 +152,12 @@ class CQuorumManager final size_t nCountRequested, const CChain& chain) const EXCLUSIVE_LOCKS_REQUIRED(::cs_main, !cs_db, !m_cs_maps, !m_cache_cs); + std::vector ScanCommitments(Consensus::LLMQType llmqType, size_t nCountRequested) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !m_cs_maps, !m_cache_cs); + std::vector ScanCommitments(Consensus::LLMQType llmqType, gsl::not_null pindexStart, + size_t nCountRequested) const + EXCLUSIVE_LOCKS_REQUIRED(!cs_db, !m_cs_maps, !m_cache_cs); + bool IsMasternode() const; bool IsWatching() const; @@ -215,6 +223,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/rpc/client.cpp b/src/rpc/client.cpp index 972e99c94924..419d6d154250 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -351,6 +351,12 @@ static const CRPCConvertParam vRPCConvertParams[] = { "quorum sign", 5, "submit" }, { "quorum verify", 1, "llmqType" }, { "quorum verify", 6, "signHeight" }, + { "getchainlockbyheight", 0, "height" }, + { "getquorumproofchain", 1, "height" }, + { "getquorumproofchain", 3, "llmq_type" }, + { "getquorumproofchain", 4, "node_count" }, + { "verifyquorumproofchain", 0, "checkpoint" }, + { "verifyquorumproofchain", 2, "minimum_height" }, }; // clang-format on diff --git a/src/rpc/quorums.cpp b/src/rpc/quorums.cpp index 5bd168e9b0bf..3a68a20afeb6 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,214 @@ 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; + { + LOCK(cs_main); + chain.SetTip(*CHECK_NONFATAL(chainman.ActiveChain().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 archived 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{4}, "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\" 0 \"\" 0 4")}, + [&](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() ? 4 : 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; + { + LOCK(cs_main); + chain.SetTip(*CHECK_NONFATAL(chainman.ActiveChain().Tip())); + 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 + llmq::MAX_PROOF_HEADERS))); + } + if (!target_chainlock || target_chainlock->clsig.getHeight() <= checkpoint->nHeight) + throw std::runtime_error("No archived certificate within search budget"); + const auto* target = chain[target_chainlock->clsig.getHeight()]; + llmq::QuorumProofBuilder builder(*ctx.quorum_block_processor, *ctx.qman, chain, chainman.m_blockman, + reader); + auto proof = builder.Build(checkpoint, *target_chainlock); + 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_chainlock->carrier)) + 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", "'{...}' \"proof_hex\"")}, + [&](const RPCHelpMan&, const JSONRPCRequest& request) -> UniValue { + UniValue result(UniValue::VOBJ); + try { + const auto trusted = llmq::ProofState::FromJson(request.params[0]); + const auto text = request.params[1].get_str(); + if (text.size() > llmq::MAX_PROOF_BYTES * 2 || !IsHex(text)) throw std::runtime_error("Proof hex size/encoding"); + auto proof = llmq::QuorumProofChain::Decode(ParseHex(text)); + auto target = proof.Verify(trusted); + const auto minimum = request.params[2].isNull() ? 0 : request.params[2].getInt(); + 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 +1624,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/src/test/llmq_chainlock_tests.cpp b/src/test/llmq_chainlock_tests.cpp index 008a5c261b1f..9fdb80c65ae8 100644 --- a/src/test/llmq_chainlock_tests.cpp +++ b/src/test/llmq_chainlock_tests.cpp @@ -18,13 +18,20 @@ #include #include +#include #include +#include +#include +#include #include #include +#include +#include #include #include +#include #include #include @@ -39,6 +46,93 @@ 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 + 3) { + const int certified = height < activation + 67 ? activation + 1 + : height < activation + 100 ? activation + 65 + : 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 + 3 + : expected == activation + 65 ? activation + 67 + : activation + 100; + 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 diff --git a/src/test/quorum_proofs_tests.cpp b/src/test/quorum_proofs_tests.cpp new file mode 100644 index 000000000000..417e3ebacd8a --- /dev/null +++ b/src/test/quorum_proofs_tests.cpp @@ -0,0 +1,143 @@ +// Copyright (c) 2025-2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying file COPYING. +#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(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/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..9b8c87d84453 100755 --- a/test/functional/feature_llmq_chainlocks.py +++ b/test/functional/feature_llmq_chainlocks.py @@ -292,6 +292,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..977b6b37937a --- /dev/null +++ b/test/functional/feature_quorum_proof_chain.py @@ -0,0 +1,64 @@ +#!/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:], "00" * 1048577): + assert_equal(node.verifyquorumproofchain(anchor, invalid)["valid"], False) + 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 adf7588f3f4b..fd9a259ef885 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -115,6 +115,7 @@ 'feature_llmq_signing.py', # NOTE: needs dash_hash to pass 'feature_llmq_is_retroactive.py', # NOTE: needs dash_hash to pass 'feature_llmq_chainlocks.py', # NOTE: needs dash_hash to pass + 'feature_quorum_proof_chain.py', '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