feat(consensus): intersect expected proposers with UNL Report and adaptive quorum

setExpectedProposers() now filters incoming proposers against the
on-chain UNL Report, preventing non-UNL nodes from inflating the
expected set and causing unnecessary timeouts.

quorumThreshold() uses expectedProposers_.size() (recent proposers ∩
UNL) when available, falling back to full UNL Report count on cold
boot. This adapts to actual network conditions rather than relying
on a potentially stale UNL Report that over-counts offline validators.

Renamed activeUNLNodeIds_/cacheActiveUNL/isActiveUNLMember to
unlReportNodeIds_/cacheUNLReport/isUNLReportMember to make the
on-chain data source explicit.
This commit is contained in:
Nicholas Dudfield
2026-02-10 16:14:47 +07:00
parent bc98c589b7
commit 41a41ec625
3 changed files with 49 additions and 52 deletions

View File

@@ -454,7 +454,7 @@ RCLConsensus::Adaptor::onClose(
// falling back to ZERO entropy) until the rejoiner starts proposing.
if (proposing && prevLedger->rules().enabled(featureConsensusEntropy))
{
cacheActiveUNL();
cacheUNLReport();
generateEntropySecret();
pos.myCommitment = sha512Half(
myEntropySecret_,
@@ -1154,25 +1154,15 @@ RCLConsensus::Adaptor::updateOperatingMode(std::size_t const positions) const
std::size_t
RCLConsensus::Adaptor::quorumThreshold() const
{
// Base quorum on the active UNL — the same set used by
// isActiveUNLMember() to filter RNG data. This comes from the
// UNL Report (in-ledger) with fallback to the trusted key list.
// If a node drops off the UNL Report, the denominator shrinks
// and quorum becomes achievable with fewer participants.
// Prefer expected proposers (recent proposers ∩ UNL) — this
// adapts to actual network conditions rather than relying on
// the potentially stale UNL Report. Falls back to full
// UNL Report for cold boot (first round).
//
// TODO: This needs more careful thought. Open questions:
// - Should there be a minimum absolute count (e.g. at least 3
// committers) to prevent weak entropy from tiny partitions?
// - Is 80% the right percentage for RNG, or should it differ
// from the tx consensus threshold?
// - What happens if the UNL Report is stale and over-counts
// active validators? The "impossible quorum" early-exit in
// Consensus.h mitigates the worst case (no delay), but the
// node still falls back to ZERO entropy.
// - On a fresh network with no UNL Report, the fallback is
// getTrustedMasterKeys() which includes all configured
// validators — possibly including offline ones.
auto const base = activeUNLNodeIds_.size();
// Round 1: threshold based on full UNL (conservative)
// Round 2+: threshold based on who actually proposed last round
auto const base = expectedProposers_.empty() ? unlReportNodeIds_.size()
: expectedProposers_.size();
if (base == 0)
return 1; // safety: need at least one commit
return (base * 80 + 99) / 100;
@@ -1183,26 +1173,33 @@ RCLConsensus::Adaptor::setExpectedProposers(hash_set<NodeID> proposers)
{
if (!proposers.empty())
{
// Recent proposers from last round — best signal for who's active.
// Always include ourselves.
proposers.insert(validatorKeys_.nodeID);
expectedProposers_ = std::move(proposers);
// Intersect with active UNL — only expect commits from
// validators we trust. Non-UNL proposers are ignored.
hash_set<NodeID> filtered;
for (auto const& id : proposers)
{
if (unlReportNodeIds_.count(id))
filtered.insert(id);
}
filtered.insert(validatorKeys_.nodeID);
expectedProposers_ = std::move(filtered);
JLOG(j_.debug()) << "RNG: expectedProposers from recent proposers: "
<< expectedProposers_.size();
<< expectedProposers_.size() << " (filtered from "
<< proposers.size() << ")";
return;
}
// First round (no previous proposers): fall back to activeUNL.
// cacheActiveUNL() was called just before this, so it's populated.
if (!activeUNLNodeIds_.empty())
// First round (no previous proposers): fall back to UNL Report.
// cacheUNLReport() was called just before this, so it's populated.
if (!unlReportNodeIds_.empty())
{
expectedProposers_ = activeUNLNodeIds_;
JLOG(j_.debug()) << "RNG: expectedProposers from activeUNL: "
expectedProposers_ = unlReportNodeIds_;
JLOG(j_.debug()) << "RNG: expectedProposers from UNL Report: "
<< expectedProposers_.size();
return;
}
// No data at all (shouldn't happen — cacheActiveUNL falls back to
// No data at all (shouldn't happen — cacheUNLReport falls back to
// trusted keys). Leave empty → hasQuorumOfCommits uses 80% fallback.
JLOG(j_.warn()) << "RNG: no expectedProposers available";
}
@@ -1277,7 +1274,7 @@ RCLConsensus::Adaptor::buildCommitSet(LedgerIndex seq)
for (auto const& [nodeId, commit] : pendingCommits_)
{
if (!isActiveUNLMember(nodeId))
if (!isUNLReportMember(nodeId))
continue;
auto kit = nodeIdToKey_.find(nodeId);
@@ -1329,7 +1326,7 @@ RCLConsensus::Adaptor::buildEntropySet(LedgerIndex seq)
for (auto const& [nodeId, reveal] : pendingReveals_)
{
if (!isActiveUNLMember(nodeId))
if (!isUNLReportMember(nodeId))
continue;
auto kit = nodeIdToKey_.find(nodeId);
@@ -1407,16 +1404,16 @@ RCLConsensus::Adaptor::clearRngState()
commitSetMap_.reset();
entropySetMap_.reset();
pendingRngFetches_.clear();
activeUNLNodeIds_.clear();
unlReportNodeIds_.clear();
expectedProposers_.clear();
commitProofs_.clear();
proposalProofs_.clear();
}
void
RCLConsensus::Adaptor::cacheActiveUNL()
RCLConsensus::Adaptor::cacheUNLReport()
{
activeUNLNodeIds_.clear();
unlReportNodeIds_.clear();
// Try UNL Report from the validated ledger
if (auto const prevLedger = ledgerMaster_.getValidatedLedger())
@@ -1430,7 +1427,7 @@ RCLConsensus::Adaptor::cacheActiveUNL()
auto const pk = obj.getFieldVL(sfPublicKey);
if (publicKeyType(makeSlice(pk)))
{
activeUNLNodeIds_.insert(
unlReportNodeIds_.insert(
calcNodeID(PublicKey(makeSlice(pk))));
}
}
@@ -1439,24 +1436,24 @@ RCLConsensus::Adaptor::cacheActiveUNL()
}
// Fallback to normal UNL if no report or empty
if (activeUNLNodeIds_.empty())
if (unlReportNodeIds_.empty())
{
for (auto const& masterKey : app_.validators().getTrustedMasterKeys())
{
activeUNLNodeIds_.insert(calcNodeID(masterKey));
unlReportNodeIds_.insert(calcNodeID(masterKey));
}
}
// Always include ourselves
activeUNLNodeIds_.insert(validatorKeys_.nodeID);
unlReportNodeIds_.insert(validatorKeys_.nodeID);
JLOG(j_.debug()) << "RNG: cacheActiveUNL size=" << activeUNLNodeIds_.size();
JLOG(j_.debug()) << "RNG: cacheUNLReport size=" << unlReportNodeIds_.size();
}
bool
RCLConsensus::Adaptor::isActiveUNLMember(NodeID const& nodeId) const
RCLConsensus::Adaptor::isUNLReportMember(NodeID const& nodeId) const
{
return activeUNLNodeIds_.count(nodeId) > 0;
return unlReportNodeIds_.count(nodeId) > 0;
}
bool
@@ -1543,7 +1540,7 @@ RCLConsensus::Adaptor::handleAcquiredRngSet(std::shared_ptr<SHAMap> const& map)
NodeID nodeId;
std::memcpy(nodeId.data(), acctId.data(), nodeId.size());
if (!isActiveUNLMember(nodeId))
if (!isUNLReportMember(nodeId))
{
JLOG(j_.debug()) << "RNG: rejecting non-UNL entry from "
<< nodeId << " in acquired set";
@@ -1599,7 +1596,7 @@ RCLConsensus::Adaptor::handleAcquiredRngSet(std::shared_ptr<SHAMap> const& map)
NodeID nodeId;
std::memcpy(nodeId.data(), acctId.data(), nodeId.size());
if (!isActiveUNLMember(nodeId))
if (!isUNLReportMember(nodeId))
{
JLOG(j_.debug()) << "RNG: rejecting non-UNL entry from "
<< nodeId << " in acquired set";
@@ -1758,7 +1755,7 @@ RCLConsensus::Adaptor::harvestRngData(
<< " reveal=" << (position.myReveal ? "yes" : "no");
// Reject data from validators not in the active UNL
if (!isActiveUNLMember(nodeId))
if (!isUNLReportMember(nodeId))
{
JLOG(j_.debug()) << "RNG: rejecting data from non-UNL validator "
<< nodeId;

View File

@@ -105,10 +105,10 @@ class RCLConsensus
hash_set<uint256> pendingRngFetches_;
// Cached set of NodeIDs from UNL Report (or fallback UNL)
hash_set<NodeID> activeUNLNodeIds_;
hash_set<NodeID> unlReportNodeIds_;
// Expected proposers for commit quorum — derived from last round's
// actual proposers (best signal), falling back to activeUNL.
// actual proposers (best signal), falling back to UNL Report.
hash_set<NodeID> expectedProposers_;
/** Proof data from a proposal signature, for embedding in SHAMap
@@ -228,7 +228,7 @@ class RCLConsensus
quorumThreshold() const;
/** Set expected proposers for this round's commit quorum.
Cascade: recent proposers > activeUNL > (empty = 80% fallback).
Cascade: recent proposers > UNL Report > (empty = 80% fallback).
*/
void
setExpectedProposers(hash_set<NodeID> proposers);
@@ -279,11 +279,11 @@ class RCLConsensus
Reads from UNL Report (in-ledger), falls back to normal UNL.
*/
void
cacheActiveUNL();
cacheUNLReport();
/** Check if a NodeID is in the active UNL for this round */
bool
isActiveUNLMember(NodeID const& nodeId) const;
isUNLReportMember(NodeID const& nodeId) const;
/** Generate new entropy secret for this round */
void

View File

@@ -744,8 +744,8 @@ Consensus<Adaptor>::startRoundInternal(
// Populate UNL cache for all nodes (including observers).
// onClose only caches for proposing validators, so observers
// would otherwise have an empty set and reject all RNG data.
adaptor_.cacheActiveUNL();
// Set expected proposers: recent proposers > activeUNL > 80% fallback
adaptor_.cacheUNLReport();
// Set expected proposers: recent proposers > UNL Report > 80% fallback
adaptor_.setExpectedProposers(std::move(lastProposers));
}