Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions src/active/dkgsession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#include <active/masternode.h>
#include <evo/deterministicmns.h>
#include <llmq/blockprocessor.h>
#include <llmq/commitment.h>
#include <llmq/debug.h>
#include <llmq/dkgsessionhandler.h>
Expand All @@ -23,13 +24,45 @@
#include <cxxtimer.hpp>

namespace llmq {
namespace {
//! About a week of mainnet blocks
constexpr int SIGNING_HISTORY_WINDOW{4032};
constexpr int SIGNING_HISTORY_MIN_COMMITMENTS{6};
} // anonymous namespace

std::pair<int, int> CountSignedCommitments(const SigningRecords& records, const CDeterministicMNList& mn_list,
const uint256& proTxHash)
{
auto it = records.find(proTxHash);
const auto dmn = mn_list.GetMN(proTxHash);
if (it == records.end() || !dmn) {
return {0, 0};
}
const int cutoff{std::max(dmn->pdmnState->nRegisteredHeight, dmn->pdmnState->nPoSeRevivedHeight)};
int valid{0}, signed_count{0};
for (const auto& record : it->second) {
if (record.quorum_height > cutoff) {
++valid;
signed_count += record.signed_commitment;
}
}
return {valid, signed_count};
}

bool HasInsufficientSigningHistory(int valid, int signed_count)
{
return valid >= SIGNING_HISTORY_MIN_COMMITMENTS && 4 * signed_count <= valid;
}

ActiveDKGSession::ActiveDKGSession(CBLSWorker& bls_worker, CDeterministicMNManager& dmnman, CDKGDebugManager& dkgdbgman,
CDKGSessionManager& qdkgsman, CMasternodeMetaMan& mn_metaman,
CQuorumSnapshotManager& qsnapman, const CActiveMasternodeManager& mn_activeman,
const CQuorumBlockProcessor& qblockman, CQuorumSnapshotManager& qsnapman,
const CActiveMasternodeManager& mn_activeman,
const ChainstateManager& chainman, const CSporkManager& sporkman,
const CBlockIndex* base_block_index, const Consensus::LLMQParams& llmq_params) :
CDKGSession(bls_worker, dmnman, dkgdbgman, qdkgsman, qsnapman, chainman, base_block_index, llmq_params),
m_mn_metaman{mn_metaman},
m_qblockman{qblockman},
m_mn_activeman{mn_activeman},
m_sporkman{sporkman},
m_use_legacy_bls{!DeploymentActiveAfter(m_quorum_base_block_index, Params().GetConsensus(), Consensus::DEPLOYMENT_V19)}
Expand Down Expand Up @@ -285,6 +318,15 @@ void ActiveDKGSession::VerifyConnectionAndMinProtoVersions(CConnman& connman) co
}
});

const int base_height{m_quorum_base_block_index->nHeight};
const auto signing_records = m_qblockman.GetSigningRecords(
m_quorum_base_block_index->GetAncestor(base_height - base_height % params.dkgInterval), SIGNING_HISTORY_WINDOW);
if (!signing_records) {
LogPrint(BCLog::LLMQ_DKG, "ActiveDKGSession::%s -- signing history unavailable after chain change, skipping signing-history check\n", __func__);
}
// Members of a rotated quorum keep the state they had when their quarter was built
const auto mn_list = m_dmnman.GetListForBlock(m_quorum_base_block_index);

bool fShouldAllMembersBeConnected = IsAllMembersConnectedEnabled(params.type, m_sporkman);
for (const auto& m : members) {
if (m->dmn->proTxHash == myProTxHash) {
Expand Down Expand Up @@ -315,6 +357,14 @@ void ActiveDKGSession::VerifyConnectionAndMinProtoVersions(CConnman& connman) co
m->badConnection = true;
logger.Batch("%s is Platform PoSe banned", m->dmn->proTxHash.ToString());
}
if (signing_records) {
if (const auto [valid, signed_count] = CountSignedCommitments(*signing_records, mn_list, m->dmn->proTxHash);
HasInsufficientSigningHistory(valid, signed_count)) {
m->badConnection = true;
logger.Batch("%s signed only %d of the %d recent commitments it was a valid member of",
m->dmn->proTxHash.ToString(), signed_count, valid);
}
}
}
}

Expand Down Expand Up @@ -544,7 +594,7 @@ std::optional<CDKGPrematureCommitment> ActiveDKGSession::SendCommitment()
return std::nullopt;
}

if (ShouldSimulateError(DKGError::type::COMMIT_OMIT)) {
if (ShouldSimulateError(DKGError::type::COMMIT_OMIT) || ShouldSimulateError(DKGError::type::COMMIT_OMIT_ALL_TYPES)) {
logger.Batch("omitting");
return std::nullopt;
}
Expand Down
11 changes: 10 additions & 1 deletion src/active/dkgsession.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@
#ifndef BITCOIN_ACTIVE_DKGSESSION_H
#define BITCOIN_ACTIVE_DKGSESSION_H

#include <llmq/blockprocessor.h>
#include <llmq/dkgsession.h>

#include <optional>
#include <utility>

class CActiveMasternodeManager;
class CDeterministicMNList;
class CSporkManager;
class CMasternodeMetaMan;

namespace llmq {
std::pair<int, int> CountSignedCommitments(const SigningRecords& records, const CDeterministicMNList& mn_list,
const uint256& proTxHash);
bool HasInsufficientSigningHistory(int valid, int signed_count);

class ActiveDKGSession final : public llmq::CDKGSession
{
private:
CMasternodeMetaMan& m_mn_metaman;
const CQuorumBlockProcessor& m_qblockman;
const CActiveMasternodeManager& m_mn_activeman;
const CSporkManager& m_sporkman;
const bool m_use_legacy_bls{false};
Expand All @@ -27,7 +35,8 @@ class ActiveDKGSession final : public llmq::CDKGSession
ActiveDKGSession(const ActiveDKGSession&) = delete;
ActiveDKGSession& operator=(const ActiveDKGSession&) = delete;
ActiveDKGSession(CBLSWorker& bls_worker, CDeterministicMNManager& dmnman, CDKGDebugManager& dkgdbgman,
CDKGSessionManager& qdkgsman, CMasternodeMetaMan& mn_metaman, CQuorumSnapshotManager& qsnapman,
CDKGSessionManager& qdkgsman, CMasternodeMetaMan& mn_metaman,
const CQuorumBlockProcessor& qblockman, CQuorumSnapshotManager& qsnapman,
const CActiveMasternodeManager& mn_activeman, const ChainstateManager& chainman,
const CSporkManager& sporkman, const CBlockIndex* base_block_index,
const Consensus::LLMQParams& params);
Expand Down
2 changes: 1 addition & 1 deletion src/active/dkgsessionhandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ bool ActiveDKGSessionHandler::InitNewQuorum(gsl::not_null<const CBlockIndex*> pQ
}

auto next_session = std::make_shared<ActiveDKGSession>(m_bls_worker, m_dmnman, m_dkgdbgman, m_qdkgsman,
m_mn_metaman, m_qsnapman, m_mn_activeman, m_chainman,
m_mn_metaman, m_qblockman, m_qsnapman, m_mn_activeman, m_chainman,
m_sporkman, pQuorumBaseBlockIndex, params);

{
Expand Down
107 changes: 107 additions & 0 deletions src/llmq/blockprocessor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include <llmq/blockprocessor.h>

#include <evo/deterministicmns.h>
#include <evo/evodb.h>
#include <evo/specialtx.h>
#include <llmq/commitment.h>
Expand All @@ -27,6 +28,7 @@

#include <algorithm>
#include <map>
#include <ranges>
#include <set>
#include <utility>

Expand Down Expand Up @@ -832,6 +834,111 @@ std::vector<const CBlockIndex*> CQuorumBlockProcessor::GetMinedCommitmentsIndexe
return ret;
}

std::shared_ptr<const CQuorumBlockProcessor::ValidMemberSigners> CQuorumBlockProcessor::GetValidMemberSigners(
const CFinalCommitment& qc, gsl::not_null<const CBlockIndex*> pQuorumBaseBlockIndex) const
{
const uint256 qc_hash{::SerializeHash(qc)};
{
LOCK(m_signing_records_mutex);
if (auto it = m_valid_member_signers_cache.find(qc_hash); it != m_valid_member_signers_cache.end()) {
return it->second.second;
}
}

const auto members = utils::GetAllQuorumMembers(qc.llmqType, {m_dmnman, m_qsnapman, m_chainman, pQuorumBaseBlockIndex});
if (members.size() > qc.validMembers.size() || members.size() > qc.signers.size()) {
return nullptr;
}
if (std::any_of(qc.validMembers.begin() + members.size(), qc.validMembers.end(), [](bool valid) { return valid; }) ||
std::any_of(qc.signers.begin() + members.size(), qc.signers.end(), [](bool signed_member) { return signed_member; })) {
return nullptr;
}
auto signers = std::make_shared<ValidMemberSigners>();
for (size_t i = 0; i < members.size(); ++i) {
if (qc.validMembers[i]) {
signers->emplace_back(members[i]->proTxHash, qc.signers[i]);
}
}

LOCK(m_signing_records_mutex);
m_valid_member_signers_cache.try_emplace(qc_hash, pQuorumBaseBlockIndex->nHeight, signers);
return signers;
}

std::shared_ptr<const SigningRecords> CQuorumBlockProcessor::GetSigningRecords(gsl::not_null<const CBlockIndex*> pindex,
int window) const
{
const uint256 cache_key{::SerializeHash(std::make_pair(pindex->GetBlockHash(), window))};
{
LOCK(m_signing_records_mutex);
if (std::shared_ptr<const SigningRecords> cached; m_signing_records_cache.get(cache_key, cached)) {
return cached;
}
}

const int min_height{pindex->nHeight - window};
auto ret = std::make_shared<SigningRecords>();
std::vector<uint256> mined_block_hashes;
std::map<Consensus::LLMQType, std::vector<const CBlockIndex*>> quorums_by_type;
const auto get_quorums = [&](const Consensus::LLMQParams& params) {
const bool rotation{IsQuorumRotationEnabled(params, pindex)};
const size_t max_count{static_cast<size_t>(window / params.dkgInterval + 1) *
static_cast<size_t>(rotation ? params.signingActiveQuorumCount : 1)};
return rotation ? GetMinedCommitmentsIndexedUntilBlock(params.type, pindex, max_count)
: GetMinedCommitmentsUntilBlock(params.type, pindex, max_count);
};
for (const auto& params : Params().GetConsensus().llmqs) {
if (params.is_single_member()) {
continue;
}
const auto& quorums = quorums_by_type.emplace(params.type, get_quorums(params)).first->second;
// Oldest first, so that the member lists of the active quorums stay in the shared caches
for (const auto* pQuorumBaseBlockIndex : quorums | std::views::reverse) {
if (pQuorumBaseBlockIndex->nHeight <= min_height) {
continue;
}
const auto [qc, mined_block_hash] = GetMinedCommitment(params.type, pQuorumBaseBlockIndex->GetBlockHash());
if (qc.IsNull()) {
return nullptr;
}
const auto signers = GetValidMemberSigners(qc, pQuorumBaseBlockIndex);
if (!signers) {
return nullptr;
}
mined_block_hashes.emplace_back(mined_block_hash);
for (const auto& [proTxHash, signed_commitment] : *signers) {
(*ret)[proTxHash].push_back({pQuorumBaseBlockIndex->nHeight, signed_commitment});
Comment on lines +879 to +910

@thepastaclaw thepastaclaw Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Resolved at 99ea9483; see the replies below.

🟡 Suggestion: Add focused unit coverage for signing-history and voting boundaries

The new functional scenarios cover eventual banning, isolated omission, revival, and the spork gate, but no unit tests exercise GetSigningRecords or CountSignedCommitments. The functional test also does not deliberately cross the 4032-block history boundary or distinguish a commitment based inside the window but mined after the gathering block. These selection rules determine whether an honest member receives a bad vote. Extend the existing LLMQ unit suites with records exactly at and immediately inside the lower boundary, commitments mined at and after the gathering block, registration/revival cutoffs, and cold-versus-cached queries, including indexed rotated-quorum enumeration. Pin the eligibility and ratio decisions as well: five versus six eligible commitments and signing exactly one quarter versus just above it. Keep test-only access in the test code rather than adding a public production helper solely for testing.

source: muse-spark-1.3-contributor (phase1-reviewer: general); gpt-6.1-sol (phase2-reviewer: general, dash-core-commit-history); fresh:general

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in b98097e:

  • Window bounds: a quorum based at pindex->nHeight - window is excluded, +1 is included.
  • Commitments mined after the gathering block are excluded, for both plain and indexed (rotated) enumeration.
  • Invalid members are excluded, and cold and cached GetSigningRecords results are equal.
  • The cutoff is max(nRegisteredHeight, nPoSeRevivedHeight).
  • Eligibility at 5 vs 6 valid commitments, and the ratio at exactly 25% signed (punished) vs just above (not).

The count and the threshold check are now free functions in llmq, so the tests run the same code as ActiveDKGSession and need no test-only hooks.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (re-reviewed at 99ea9483): Your b98097e tests cover the requested selection boundaries, ordinary and indexed mining-height limits, cold/cached/fresh results, member-state cutoffs, and eligibility/ratio decisions. Both extracted decision functions are used by ActiveDKGSession, so the tests exercise production logic without test-only public hooks.

}
}
}

{
LOCK(::cs_main);
if (!m_chainman.ActiveChain().Contains(pindex)) {
return nullptr;
}
// A disconnect/reconnect can remove commitments before enumeration and restore them before this check.
for (const auto& params : Params().GetConsensus().llmqs) {
if (!params.is_single_member() && get_quorums(params) != quorums_by_type.at(params.type)) {
return nullptr;
}
}
for (const auto& hash : mined_block_hashes) {
const auto* mined_block = m_chainman.m_blockman.LookupBlockIndex(hash);
if (mined_block == nullptr || pindex->GetAncestor(mined_block->nHeight) != mined_block) {
return nullptr;
}
}
}

LOCK(m_signing_records_mutex);
// Sessions of types with a longer DKG interval gather at an older block
std::erase_if(m_valid_member_signers_cache,
[&](const auto& entry) { return entry.second.first <= min_height - window; });
m_signing_records_cache.insert(cache_key, ret);
return ret;
}

// The returned quorums are in reversed order, so the most recent one is at index 0
std::map<Consensus::LLMQType, std::vector<const CBlockIndex*>> CQuorumBlockProcessor::GetMinedAndActiveCommitmentsUntilBlock(gsl::not_null<const CBlockIndex*> pindex) const
{
Expand Down
27 changes: 27 additions & 0 deletions src/llmq/blockprocessor.h
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ using QcHashMap = std::map<Consensus::LLMQType, std::vector<uint256>>;
//! As above, but keyed by quorumIndex, for rotation-enabled types.
using QcIndexedHashMap = std::map<Consensus::LLMQType, std::map<int16_t, uint256>>;

struct SigningRecord {
int quorum_height;
bool signed_commitment;
};
using SigningRecords = Uint256HashMap<std::vector<SigningRecord>>;

/** Erase a mined commitment unless another chainstate still contains its block. */
bool EraseMinedCommitmentIfUnreferenced(CEvoDB& evo_db, const Chainstate& chainstate,
gsl::not_null<const CBlockIndex*> pindex,
Expand Down Expand Up @@ -83,6 +89,15 @@ class CQuorumBlockProcessor
mutable QcHashMap m_qc_hashes_cached GUARDED_BY(m_qc_hashes_cache_mutex);
mutable QcIndexedHashMap m_qc_indexed_hashes_cached GUARDED_BY(m_qc_hashes_cache_mutex);

using ValidMemberSigners = std::vector<std::pair<uint256, bool>>;
mutable Mutex m_signing_records_mutex;
//! By commitment hash, with the quorum base height
mutable Uint256HashMap<std::pair<int, std::shared_ptr<const ValidMemberSigners>>> m_valid_member_signers_cache
GUARDED_BY(m_signing_records_mutex);
//! By hash of the gathering block and the window; DKG sessions of a cycle share it
mutable Uint256LruHashMap<std::shared_ptr<const SigningRecords>, 4> m_signing_records_cache
GUARDED_BY(m_signing_records_mutex);

//! A block's commitment payloads are needed by CheckSpecialTx, the masternode list update,
//! ProcessBlock and the CbTx quorum merkle root to avoid duplicated decodes of BLS elements.
mutable Mutex m_commitment_payload_mutex;
Expand Down Expand Up @@ -155,7 +170,19 @@ class CQuorumBlockProcessor
const CBlockIndex* pindex,
size_t cycle) const;
std::optional<const CBlockIndex*> GetLastMinedCommitmentsByQuorumIndexUntilBlock(Consensus::LLMQType llmqType, const CBlockIndex* pindex, int quorumIndex, size_t cycle) const;

/**
* Signing records of every masternode that was a valid member of a non-null commitment mined until pindex
* for a quorum based in (pindex->nHeight - window, pindex->nHeight].
* Returns nullptr without caching the result if the view is incomplete or the chain changed underneath.
*/
std::shared_ptr<const SigningRecords> GetSigningRecords(gsl::not_null<const CBlockIndex*> pindex, int window) const
EXCLUSIVE_LOCKS_REQUIRED(!m_signing_records_mutex) LOCKS_EXCLUDED(::cs_main);
private:
std::shared_ptr<const ValidMemberSigners> GetValidMemberSigners(const CFinalCommitment& qc,
gsl::not_null<const CBlockIndex*> pQuorumBaseBlockIndex) const
EXCLUSIVE_LOCKS_REQUIRED(!m_signing_records_mutex);

//! Called from every site that writes or erases mined commitment state.
void DropQcHashesCache() EXCLUSIVE_LOCKS_REQUIRED(!m_qc_hashes_cache_mutex);

Expand Down
3 changes: 2 additions & 1 deletion src/llmq/dkgsession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ double GetSimulatedErrorRate(DKGError::type type)

bool CDKGSession::ShouldSimulateError(DKGError::type type) const
{
if (params.type != Consensus::LLMQType::LLMQ_TEST) {
if (type == DKGError::type::COMMIT_OMIT_ALL_TYPES ? !Params().IsMockableChain()
: params.type != Consensus::LLMQType::LLMQ_TEST) {
return false;
}
double rate = GetSimulatedErrorRate(type);
Expand Down
2 changes: 2 additions & 0 deletions src/llmq/dkgsession.h
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class DKGError {
JUSTIFY_LIE,
CONTRIBUTION_INVALID,
CONTRIBUTION_CLONE,
COMMIT_OMIT_ALL_TYPES,
_COUNT
};
static constexpr DKGError::type from_string(std::string_view in) {
Expand All @@ -83,6 +84,7 @@ class DKGError {
if (in == "justify-omit") return JUSTIFY_OMIT;
if (in == "contribution-invalid") return CONTRIBUTION_INVALID;
if (in == "contribution-clone") return CONTRIBUTION_CLONE;
if (in == "commit-omit-all-types") return COMMIT_OMIT_ALL_TYPES;
return _COUNT;
}
};
Expand Down
Loading
Loading