diff --git a/src/dbwrapper.cpp b/src/dbwrapper.cpp index ecc406699b32..1e6541a50664 100644 --- a/src/dbwrapper.cpp +++ b/src/dbwrapper.cpp @@ -427,6 +427,7 @@ Span CDBIterator::GetValueImpl() const CDBIterator::~CDBIterator() = default; bool CDBIterator::Valid() const { return m_impl_iter->iter->Valid(); } +bool CDBIterator::HasError() const { return !m_impl_iter->iter->status().ok(); } void CDBIterator::SeekToFirst() { m_impl_iter->iter->SeekToFirst(); } void CDBIterator::Next() { m_impl_iter->iter->Next(); } diff --git a/src/dbwrapper.h b/src/dbwrapper.h index affdb583438a..f997592fff94 100644 --- a/src/dbwrapper.h +++ b/src/dbwrapper.h @@ -163,6 +163,8 @@ class CDBIterator ~CDBIterator(); bool Valid() const; + //! True once a read failed, e.g. on a corrupt block. Entries may then have been skipped. + bool HasError() const; void SeekToFirst(); @@ -423,6 +425,10 @@ class CDBTransactionIterator return transactionIt != transaction.writes.end() || parentIt->Valid(); } + bool HasError() const { + return parentIt->HasError(); + } + void Next() { if (transactionIt == transaction.writes.end() && !parentIt->Valid()) { return; diff --git a/src/interfaces/node.h b/src/interfaces/node.h index 559746221f13..f7ce427638cb 100644 --- a/src/interfaces/node.h +++ b/src/interfaces/node.h @@ -273,16 +273,21 @@ class LLMQ int32_t m_expiry_height{0}; }; virtual std::vector getQuorumStats() = 0; - struct PlatformQuorum { - uint256 m_quorum_hash{}; - std::vector m_pubkey{}; //!< serialized BLS public key (basic scheme) - int32_t m_height{0}; + enum class PlatformQuorumStatus { FOUND, INVALID, UNAVAILABLE }; + struct PlatformQuorumResult { + PlatformQuorumStatus status{PlatformQuorumStatus::UNAVAILABLE}; + std::vector public_key; //!< 48-byte basic-scheme key for FOUND; empty otherwise }; - //! Locally retained quorums of the given LLMQ type with their public - //! keys. Used by the GUI Platform client to verify Platform state-root - //! quorum signatures against locally synced quorum data. This includes - //! retained signing quorums that are no longer in the active signing set. - virtual std::vector getPlatformQuorums(uint8_t llmq_type) = 0; + //! Whether `quorum_hash` (Core internal byte order) could sign Platform state whose signed metadata + //! carries `core_chain_locked_height`, judged from this node's active chain. FOUND means it is among + //! the quorums eligible at that height. INVALID means the local chain proves it could not: the + //! height is stale against a ChainLock this node knows, or the hash is no block of the active chain + //! below that height, so no quorum with it was mined by then, which counts only if the active + //! chain's block at that height is, or is an ancestor of, the block of the best ChainLock whose + //! block this node knows. UNAVAILABLE means this node proves neither, as for an older quorum, which + //! may still sign the one Platform block that first carries a height after a jump. + virtual PlatformQuorumResult getPlatformQuorum(uint8_t llmq_type, const uint256& quorum_hash, + uint32_t core_chain_locked_height) = 0; //! Serialized InstantSend lock for the given txid, or empty if the //! transaction has no islock (used to build asset lock proofs). virtual std::vector getInstantSendLock(const uint256& txid) = 0; diff --git a/src/llmq/blockprocessor.cpp b/src/llmq/blockprocessor.cpp index f8442e093ab4..a97513130101 100644 --- a/src/llmq/blockprocessor.cpp +++ b/src/llmq/blockprocessor.cpp @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include #include @@ -833,6 +835,111 @@ std::vector CQuorumBlockProcessor::GetMinedCommitmentsIndexe return ret; } +// Appends quorum bases from one inversed-height index, newest first, until `bases` holds +// `max_count` entries. Returns false if an entry inside `prefix` could not be read or contradicts +// the index, keeping the entries before it that the contradiction does not implicate; a key outside +// `prefix` ends the walk because no older entry exists. +static bool ReadMinedCommitmentIndex(CEvoDB& evo_db, const DataStream& prefix, const CBlockIndex& pindex, + size_t max_count, std::vector& bases) +{ + AssertLockNotHeld(evo_db.cs); + LOCK(evo_db.cs); + + const auto unreadable{[&](const char* reason) { + LogPrint(BCLog::LLMQ, "Mined commitment index below height %d unreadable after %d entries: %s\n", + pindex.nHeight, bases.size(), reason); + return false; + }}; + + DataStream first_key{prefix}; + first_key << htobe32_internal(std::numeric_limits::max() - pindex.nHeight); + + auto it{evo_db.GetCurTransaction().NewIteratorUniquePtr()}; + try { + for (it->Seek(first_key); bases.size() < max_count; it->Next()) { + // The iterator steps over a block it cannot read, so later entries are not the next newest. + if (it->HasError()) return unreadable("read error"); + if (!it->Valid()) break; + const DataStream key{it->GetKey()}; + if (key.size() < prefix.size() || !std::equal(prefix.begin(), prefix.end(), key.begin())) { + break; + } + int base_height{-1}; + if (key.size() != prefix.size() + sizeof(uint32_t) || !it->GetValue(base_height)) { + return unreadable("malformed entry"); + } + const int64_t mined_height{std::numeric_limits::max() - + be32toh_internal(ReadLE32(UCharCast(key.data() + prefix.size())))}; + const CBlockIndex* base{pindex.GetAncestor(base_height)}; + if (!base || base_height >= mined_height) return unreadable("inconsistent entry"); + // A commitment is mined within its own quorum's DKG interval, so along one index the + // quorum bases strictly decrease. Either of the two entries may be the wrong one. + if (!bases.empty() && base_height >= bases.back()->nHeight) { + bases.pop_back(); + return unreadable("out-of-order entry"); + } + bases.push_back(base); + } + } catch (const dbwrapper_error& e) { + return unreadable(e.what()); + } + return true; +} + +MinedCommitmentScan CQuorumBlockProcessor::ScanMinedCommitmentsUntilBlock(Consensus::LLMQType llmqType, + gsl::not_null pindex, + size_t max_count) const +{ + DataStream prefix{}; + prefix << DB_MINED_COMMITMENT_BY_INVERSED_HEIGHT << llmqType; + MinedCommitmentScan scan; + scan.complete = ReadMinedCommitmentIndex(m_evoDb, prefix, *pindex, max_count, scan.bases); + return scan; +} + +MinedCommitmentScan CQuorumBlockProcessor::ScanMinedCommitmentsIndexedUntilBlock(Consensus::LLMQType llmqType, + gsl::not_null pindex, + size_t max_count) const +{ + MinedCommitmentScan scan; + const auto llmq_params{Params().GetLLMQ(llmqType)}; + if (!Assume(llmq_params.has_value())) { + return scan; + } + // Cycle by cycle, the cycle-th newest commitment of every quorum index in index order. + std::vector> taken(llmq_params->signingActiveQuorumCount); + for (size_t cycle{0}; scan.bases.size() < max_count; ++cycle) { + const size_t found_before{scan.bases.size()}; + for (const int quorum_index : util::irange(llmq_params->signingActiveQuorumCount)) { + if (scan.bases.size() == max_count) break; + DataStream prefix{}; + prefix << DB_MINED_COMMITMENT_BY_INVERSED_HEIGHT_Q_INDEXED << llmqType << quorum_index; + std::vector index_bases; + if (!ReadMinedCommitmentIndex(m_evoDb, prefix, *pindex, cycle + 1, index_bases)) { + // Earlier cycles took this index's newer entries; drop any the failed read no longer keeps. + for (size_t rank{index_bases.size()}; rank < taken[quorum_index].size(); ++rank) { + std::erase(scan.bases, taken[quorum_index][rank]); + } + return scan; + } + if (index_bases.size() > cycle) { + // Quorums of different quorum indexes have different base blocks, so either entry may + // be the wrong one. + if (std::erase(scan.bases, index_bases[cycle]) > 0) { + LogPrint(BCLog::LLMQ, "Mined commitment index of quorum index %d repeats base height %d\n", + quorum_index, index_bases[cycle]->nHeight); + return scan; + } + scan.bases.push_back(index_bases[cycle]); + } + taken[quorum_index] = std::move(index_bases); + } + if (scan.bases.size() == found_before) break; + } + scan.complete = true; + return scan; +} + // The returned quorums are in reversed order, so the most recent one is at index 0 std::map> CQuorumBlockProcessor::GetMinedAndActiveCommitmentsUntilBlock(gsl::not_null pindex) const { diff --git a/src/llmq/blockprocessor.h b/src/llmq/blockprocessor.h index f506a3c99deb..790247390387 100644 --- a/src/llmq/blockprocessor.h +++ b/src/llmq/blockprocessor.h @@ -22,6 +22,7 @@ #include #include #include +#include class BlockValidationState; class CBlock; @@ -47,6 +48,17 @@ using QcHashMap = std::map>; //! As above, but keyed by quorumIndex, for rotation-enabled types. using QcIndexedHashMap = std::map>; +struct MinedCommitmentScan { + //! Quorum base blocks of mined commitments. Non-indexed scans return them newest first. Indexed + //! scans return them by cycle rank, newest rank first, and by quorum index within each rank. + std::vector bases; + //! True if `bases` holds the requested number of entries or every older entry was read; false + //! if an index entry could not be read or contradicts another, so an absent quorum may still + //! have been mined. Entries a contradiction implicates are left out. A wrong entry that fits + //! the index's order is not detected. + bool complete{false}; +}; + /** Erase a mined commitment unless another chainstate still contains its block. */ bool EraseMinedCommitmentIfUnreferenced(CEvoDB& evo_db, const Chainstate& chainstate, gsl::not_null pindex, @@ -155,6 +167,16 @@ class CQuorumBlockProcessor const CBlockIndex* pindex, size_t cycle) const; std::optional GetLastMinedCommitmentsByQuorumIndexUntilBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, int quorumIndex, size_t cycle) const; + + //! GetMinedCommitmentsUntilBlock, stopping at an entry it cannot read or trust and reporting + //! whether the walk read every entry it needed. + MinedCommitmentScan ScanMinedCommitmentsUntilBlock(Consensus::LLMQType llmqType, + gsl::not_null pindex, size_t max_count) const; + //! GetMinedCommitmentsIndexedUntilBlock for rotation-enabled types, reporting the same way. + MinedCommitmentScan ScanMinedCommitmentsIndexedUntilBlock(Consensus::LLMQType llmqType, + gsl::not_null pindex, + size_t max_count) const; + private: //! Called from every site that writes or erases mined commitment state. void DropQcHashesCache() EXCLUSIVE_LOCKS_REQUIRED(!m_qc_hashes_cache_mutex); diff --git a/src/llmq/params.h b/src/llmq/params.h index 2c975d828cb8..b9701e7f46f7 100644 --- a/src/llmq/params.h +++ b/src/llmq/params.h @@ -5,6 +5,7 @@ #ifndef BITCOIN_LLMQ_PARAMS_H #define BITCOIN_LLMQ_PARAMS_H +#include #include #include #include @@ -512,6 +513,35 @@ static constexpr std::array available_llmqs = { } // namespace Consensus +namespace llmq { +//! DKG intervals beyond its signing-active period during which a Platform quorum is still accepted +//! as the signer of Platform state. +static constexpr int PLATFORM_QUORUM_SLACK{2}; + +//! DKG intervals during which a Platform quorum may sign Platform state. A quorum of a non-rotating +//! type is signing-active for signingActiveQuorumCount intervals, one of a rotating type for one. +[[nodiscard]] constexpr int PlatformEligibleIntervals(const Consensus::LLMQParams& params) +{ + return (params.useRotation ? 1 : params.signingActiveQuorumCount) + PLATFORM_QUORUM_SLACK; +} + +//! How many of the newest mined Platform quorums may sign Platform state. A non-rotating type mines +//! one quorum per DKG interval, a rotating type one per quorum index. +[[nodiscard]] constexpr int PlatformEligibleQuorumCount(const Consensus::LLMQParams& params) +{ + return PlatformEligibleIntervals(params) * (params.useRotation ? params.signingActiveQuorumCount : 1); +} + +//! Lowest Core chain-locked height that Platform state may carry for this node to verify its +//! quorum signature, given the node's ChainLock height. Bounding by the ChainLock rather than the +//! tip keeps state signed before a ChainLock stall verifiable while the tip keeps moving. +[[nodiscard]] constexpr int PlatformQuorumMinSignedHeight(const Consensus::LLMQParams& params, int cl_height) +{ + const int64_t window{int64_t{PlatformEligibleIntervals(params)} * params.dkgInterval}; + return static_cast(std::max(0, int64_t{cl_height} - window)); +} +} // namespace llmq + // This must be outside of all namespaces. We must also duplicate the forward declaration of is_serializable_enum to // avoid inclusion of serialize.h here. template struct is_serializable_enum; diff --git a/src/node/interfaces.cpp b/src/node/interfaces.cpp index a12196d561e9..170c9502967b 100644 --- a/src/node/interfaces.cpp +++ b/src/node/interfaces.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -86,6 +88,7 @@ #include #include +#include #include #include #include @@ -684,44 +687,79 @@ class LLMQImpl : public LLMQ } return stats; } - std::vector getPlatformQuorums(uint8_t llmq_type) override + PlatformQuorumResult getPlatformQuorum(uint8_t llmq_type, const uint256& quorum_hash, + uint32_t core_chain_locked_height) override { - std::vector ret; + PlatformQuorumResult invalid{PlatformQuorumStatus::INVALID, {}}; + const auto type{static_cast(llmq_type)}; + const auto llmq_params{Params().GetLLMQ(type)}; + if (type != Params().GetConsensus().llmqTypePlatform || !llmq_params.has_value()) { + return invalid; + } + if (core_chain_locked_height > static_cast(std::numeric_limits::max())) { + return invalid; + } + const int signed_height{static_cast(core_chain_locked_height)}; if (!context().llmq_ctx || !context().llmq_ctx->quorum_block_processor || !context().chainman) { - return ret; + return {}; } - const auto* pindex{WITH_LOCK(::cs_main, return context().chainman->ActiveChain().Tip())}; - if (!pindex) { - return ret; + // Only a ChainLock whose block is known can lock part of the active chain; the best height may + // belong to a block this node has not seen. + const CBlockIndex* chainlocked_block{nullptr}; + int32_t best_cl_height{-1}; + if (const auto& chainlocks{context().chainlocks}) { + chainlocked_block = chainlocks->GetBestChainlockWithPindex().second; + best_cl_height = chainlocks->GetBestChainLockHeight(); } - const auto type{static_cast(llmq_type)}; - const auto llmq_params{Params().GetLLMQ(type)}; - if (!llmq_params.has_value()) { - return ret; + + LOCK(::cs_main); + const CChain& active{context().chainman->ActiveChain()}; + const CBlockIndex* tip{active.Tip()}; + if (!tip || signed_height > tip->nHeight) { + return {}; } - // Drive proofs may be signed by an older Platform quorum while they - // are still consensus-valid and retained locally. Export the full - // retained-key window, not only the current signing-active set. - const auto quorum_count{static_cast(std::max(llmq_params->signingActiveQuorumCount, - llmq_params->keepOldKeys))}; - // Read mined final commitments directly: they already carry the quorum - // hash and public key, so there is no need to materialize full CQuorum - // objects (member lists, vvec/contribution reads, quorum cache inserts) - // via ScanQuorums. Newest-first, matching ScanQuorums' ordering. + // A ChainLock can arrive before its block. Without one the tip still proves a height fresh, + // but not stale: Platform may carry a ChainLock this node has not learned yet. + const int cl_height{best_cl_height >= 0 ? std::min(best_cl_height, tip->nHeight) : tip->nHeight}; + if (signed_height < llmq::PlatformQuorumMinSignedHeight(*llmq_params, cl_height)) { + return best_cl_height >= 0 ? invalid : PlatformQuorumResult{}; + } + const auto& qbp{*context().llmq_ctx->quorum_block_processor}; - const auto quorum_base_block_indexes{llmq_params->useRotation - ? qbp.GetMinedCommitmentsIndexedUntilBlock(type, pindex, quorum_count) - : qbp.GetMinedCommitmentsUntilBlock(type, pindex, quorum_count)}; - for (const auto* pQuorumBaseBlockIndex : quorum_base_block_indexes) { - const auto qc{qbp.GetMinedCommitment(type, pQuorumBaseBlockIndex->GetBlockHash()).first}; - if (!qc.quorumPublicKey.IsValid()) continue; - ret.emplace_back(PlatformQuorum{ - .m_quorum_hash = qc.quorumHash, - .m_pubkey = qc.quorumPublicKey.ToByteVector(/*specificLegacyScheme=*/false), - .m_height = pQuorumBaseBlockIndex->nHeight, - }); + const CBlockIndex* signed_block{active[signed_height]}; + const auto window{static_cast(llmq::PlatformEligibleQuorumCount(*llmq_params))}; + const auto scan{llmq_params->useRotation ? qbp.ScanMinedCommitmentsIndexedUntilBlock(type, signed_block, window) + : qbp.ScanMinedCommitmentsUntilBlock(type, signed_block, window)}; + if (std::ranges::none_of(scan.bases, [&](const CBlockIndex* base) { return base->GetBlockHash() == quorum_hash; })) { + // Platform verifies a block against its last committed validator set, so the block that + // first carries a height may be signed by a quorum no longer in the window at that height. + // A quorum mined at or below the height has its base block on the active chain below it; + // any other hash could never have signed. + const CBlockIndex* base{context().chainman->m_blockman.LookupBlockIndex(quorum_hash)}; + const bool never_mined{!base || base->nHeight >= signed_height || !active.Contains(base)}; + // That is final only on the part of the active chain a ChainLock locks; beyond it the + // active chain may be a competing branch. Unlike Chainlocks::HasChainLock, this keeps the + // lock while spork 19 is off. A node whose quorum index reads as corrupt may have bad storage + // elsewhere too, so it rejects nothing until the window reads cleanly. + const bool chainlocked{chainlocked_block && chainlocked_block->GetAncestor(signed_height) == signed_block}; + return never_mined && chainlocked && scan.complete ? invalid : PlatformQuorumResult{}; } - return ret; + + // Mined commitments passed consensus validation, so an unreadable or inconsistent record is + // local corruption, not evidence against the quorum. + std::pair record; + try { + record = qbp.GetMinedCommitment(type, quorum_hash); + } catch (const dbwrapper_error&) { + return {}; + } + const auto& [qc, mined_block_hash]{record}; + const CBlockIndex* mined_block{context().chainman->m_blockman.LookupBlockIndex(mined_block_hash)}; + if (qc.llmqType != type || qc.quorumHash != quorum_hash || !mined_block || !active.Contains(mined_block) || + mined_block->nHeight > signed_height || !qc.quorumPublicKey.IsValid()) { + return {}; + } + return {PlatformQuorumStatus::FOUND, qc.quorumPublicKey.ToByteVector(/*specificLegacyScheme=*/false)}; } std::vector getInstantSendLock(const uint256& txid) override { diff --git a/src/test/interfaces_tests.cpp b/src/test/interfaces_tests.cpp index 2bdca0b6f459..2a9d9fbf06f1 100644 --- a/src/test/interfaces_tests.cpp +++ b/src/test/interfaces_tests.cpp @@ -3,6 +3,7 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include #include #include #include @@ -13,21 +14,30 @@ #include #include #include +#include +#include #include