feat(consensus): add active participant diagnostics

Carry a signed observedParticipantsHash in ExtendedPosition and log a local canonical bitmap over the active validator view for degraded-round debugging. The field is diagnostic only and does not affect proposal equality or quorum thresholds.
This commit is contained in:
Nicholas Dudfield
2026-05-21 09:56:51 +08:00
parent 0a77dbf68e
commit 03e0bb5fc3
7 changed files with 274 additions and 8 deletions

View File

@@ -66,6 +66,7 @@ class ExtendedPosition_test : public beast::unit_test::suite
BEAST_EXPECT(!deserialized->entropySetHash);
BEAST_EXPECT(!deserialized->exportSigSetHash);
BEAST_EXPECT(!deserialized->exportSignaturesHash);
BEAST_EXPECT(!deserialized->observedParticipantsHash);
}
// Position with commitment
@@ -94,6 +95,34 @@ class ExtendedPosition_test : public beast::unit_test::suite
BEAST_EXPECT(!deserialized->myReveal);
}
// Position with diagnostic participant hash only
{
auto const txSet = makeHash("txset-participants");
auto const participants = makeHash("participants");
ExtendedPosition pos{txSet};
pos.observedParticipantsHash = participants;
Serializer s;
pos.add(s);
// 32 (txSet) + 1 (flags) + 32 (participant hash) = 65
BEAST_EXPECT(s.getDataLength() == 65);
SerialIter sit(s.slice());
auto deserialized =
ExtendedPosition::fromSerialIter(sit, s.getDataLength());
BEAST_EXPECT(deserialized.has_value());
if (!deserialized)
return;
BEAST_EXPECT(deserialized->txSetHash == txSet);
BEAST_EXPECT(
deserialized->observedParticipantsHash == participants);
BEAST_EXPECT(!deserialized->myCommitment);
BEAST_EXPECT(!deserialized->myReveal);
}
// Position with all fields
{
auto const txSet = makeHash("txset-c");
@@ -101,6 +130,7 @@ class ExtendedPosition_test : public beast::unit_test::suite
auto const entropySet = makeHash("entropyset-c");
auto const exportSigSet = makeHash("exportsigset-c");
auto const exportSigs = makeHash("exportsigs-c");
auto const participants = makeHash("participants-c");
auto const commit = makeHash("commit-c");
auto const reveal = makeHash("reveal-c");
@@ -109,14 +139,15 @@ class ExtendedPosition_test : public beast::unit_test::suite
pos.entropySetHash = entropySet;
pos.exportSigSetHash = exportSigSet;
pos.exportSignaturesHash = exportSigs;
pos.observedParticipantsHash = participants;
pos.myCommitment = commit;
pos.myReveal = reveal;
Serializer s;
pos.add(s);
// 32 + 1 + 6*32 = 225
BEAST_EXPECT(s.getDataLength() == 225);
// 32 + 1 + 7*32 = 257
BEAST_EXPECT(s.getDataLength() == 257);
SerialIter sit(s.slice());
auto deserialized =
@@ -130,6 +161,8 @@ class ExtendedPosition_test : public beast::unit_test::suite
BEAST_EXPECT(deserialized->entropySetHash == entropySet);
BEAST_EXPECT(deserialized->exportSigSetHash == exportSigSet);
BEAST_EXPECT(deserialized->exportSignaturesHash == exportSigs);
BEAST_EXPECT(
deserialized->observedParticipantsHash == participants);
BEAST_EXPECT(deserialized->myCommitment == commit);
BEAST_EXPECT(deserialized->myReveal == reveal);
}
@@ -359,7 +392,7 @@ class ExtendedPosition_test : public beast::unit_test::suite
auto const txSet = makeHash("txset-unkflags");
Serializer s;
s.addBitString(txSet);
s.add8(0x41); // bit 6 is unknown, bit 0 = commitSetHash
s.add8(0x81); // bit 7 is unknown, bit 0 = commitSetHash
s.addBitString(makeHash("commitset-unkflags"));
SerialIter sit(s.slice());
auto result =
@@ -433,6 +466,10 @@ class ExtendedPosition_test : public beast::unit_test::suite
b.exportSignaturesHash = makeHash("export-sigs-eq");
BEAST_EXPECT(a == b);
// Same txSetHash, different participant diagnostics -> still equal
b.observedParticipantsHash = makeHash("participants-eq");
BEAST_EXPECT(a == b);
// Different txSetHash -> not equal
ExtendedPosition c{txSet2};
BEAST_EXPECT(a != c);

View File

@@ -59,6 +59,23 @@ ConsensusExtensions::ConsensusExtensions(Application& app, beast::Journal j)
namespace {
std::string
buildObservedParticipantBitmap(
std::vector<NodeID> const& activeSorted,
hash_set<NodeID> const& observed)
{
std::string bitmapBin;
bitmapBin.reserve(activeSorted.size());
for (std::size_t i = 0; i < activeSorted.size(); ++i)
{
bool const seen = observed.count(activeSorted[i]) != 0;
bitmapBin.push_back(seen ? '1' : '0');
}
return bitmapBin;
}
ConsensusExtensions::ActiveValidatorView
buildActiveValidatorView(
Application& app,
@@ -817,6 +834,9 @@ ConsensusExtensions::clearRngState()
consensusExportTxns_.clear();
consensusTxSetHash_.reset();
pendingRngFetches_.clear();
observedParticipantsHash_.reset();
observedParticipantsCount_ = 0;
observedParticipantsBitmapBin_.clear();
exportSigGateStarted_ = false;
exportSigGateStart_ = {};
exportSigConvergenceFailed_ = false;
@@ -1335,6 +1355,104 @@ ConsensusExtensions::fetchSidecarsIfNeeded(ExtendedPosition const& peerPos)
fetchRngSetIfNeeded(peerPos.exportSigSetHash, SidecarKind::exportSig);
}
void
ConsensusExtensions::recordParticipantDiagnostics(
ConsensusMode mode,
std::vector<NodeID> peerNodeIds)
{
auto const view = activeValidatorView();
hash_set<NodeID> observed;
observed.reserve(peerNodeIds.size() + 1);
for (auto const& nodeId : peerNodeIds)
{
if (view->containsNode(nodeId))
observed.insert(nodeId);
}
auto const& valKeys = app_.getValidatorKeys();
if (mode == ConsensusMode::proposing && valKeys.nodeID != beast::zero &&
view->containsNode(valKeys.nodeID))
{
observed.insert(valKeys.nodeID);
}
std::vector<NodeID> activeSorted(
view->nodeIds.begin(), view->nodeIds.end());
std::sort(activeSorted.begin(), activeSorted.end());
std::vector<NodeID> observedSorted(observed.begin(), observed.end());
std::sort(observedSorted.begin(), observedSorted.end());
auto bitmapBin = buildObservedParticipantBitmap(activeSorted, observed);
Serializer s(512);
if (view->sourceLedgerHash)
s.addBitString(*view->sourceLedgerHash);
else
{
uint256 noSource;
noSource.zero();
s.addBitString(noSource);
}
s.add32(static_cast<std::uint32_t>(view->size()));
for (auto const& nodeId : activeSorted)
s.addBitString(nodeId);
s.add32(static_cast<std::uint32_t>(observedSorted.size()));
for (auto const& nodeId : observedSorted)
s.addBitString(nodeId);
auto const hash = s.getSHA512Half();
bool const changed = !observedParticipantsHash_ ||
*observedParticipantsHash_ != hash ||
observedParticipantsCount_ != observedSorted.size() ||
observedParticipantsBitmapBin_ != bitmapBin;
observedParticipantsHash_ = hash;
observedParticipantsCount_ = observedSorted.size();
observedParticipantsBitmapBin_ = std::move(bitmapBin);
if (changed)
{
JLOG(j_.debug()) << "STALLDIAG: observed-active-participants"
<< " count=" << observedParticipantsCount_
<< " activeView=" << view->size()
<< " quorum=" << quorumThreshold() << " hash=" << hash
<< " source="
<< (view->fromUNLReport ? "UNLReport"
: "trusted-fallback")
<< " mode=" << to_string(mode)
<< " peerPositions=" << peerNodeIds.size()
<< " bitmap=" << observedParticipantsBitmapBin_;
}
}
void
ConsensusExtensions::attachParticipantDiagnostics(ExtendedPosition& pos) const
{
if (!rngEnabledThisRound_ && !exportEnabledThisRound_)
return;
if (observedParticipantsHash_)
pos.observedParticipantsHash = observedParticipantsHash_;
}
std::size_t
ConsensusExtensions::observedParticipantCount() const
{
return observedParticipantsCount_;
}
std::optional<uint256>
ConsensusExtensions::observedParticipantsHash() const
{
return observedParticipantsHash_;
}
std::string const&
ConsensusExtensions::observedParticipantsBitmapBin() const
{
return observedParticipantsBitmapBin_;
}
void
ConsensusExtensions::cacheConsensusTxSet(RCLTxSet const& txns)
{
@@ -1870,6 +1988,13 @@ ConsensusExtensions::appendJson(Json::Value& ret) const
rng["any_reveals"] = hasAnyReveals();
rng["reveals"] = static_cast<Int>(pendingRevealCount());
rng["likely_participants"] = static_cast<Int>(expectedProposerCount());
rng["observed_active_participants"] =
static_cast<Int>(observedParticipantCount());
if (observedParticipantsHash_)
{
rng["observed_participants"] = to_string(*observedParticipantsHash_);
rng["observed_participants_bitmap"] = observedParticipantsBitmapBin_;
}
ret["rng"] = std::move(rng);
}
@@ -1890,6 +2015,10 @@ ConsensusExtensions::logPosition(
<< " entropySetHash="
<< (pos.entropySetHash ? to_string(*pos.entropySetHash)
: std::string{"none"})
<< " observedParticipantsHash="
<< (pos.observedParticipantsHash
? to_string(*pos.observedParticipantsHash)
: std::string{"none"})
<< " myCommitment=" << (pos.myCommitment ? "yes" : "no")
<< " myReveal=" << (pos.myReveal ? "yes" : "no");
}

View File

@@ -17,6 +17,7 @@
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace ripple {
@@ -120,6 +121,9 @@ private:
// Recent proposers intersected with the active UNL (liveness hint)
hash_set<NodeID> likelyParticipants_;
std::optional<uint256> observedParticipantsHash_;
std::size_t observedParticipantsCount_ = 0;
std::string observedParticipantsBitmapBin_;
// Current consensus mode (set by adaptor at round start)
ConsensusMode mode_{ConsensusMode::observing};
@@ -274,6 +278,36 @@ public:
void
fetchSidecarsIfNeeded(ExtendedPosition const& peerPos);
template <class PeerPositions>
void
recordParticipantDiagnostics(
ConsensusMode mode,
PeerPositions const& peerPositions)
{
std::vector<NodeID> peerNodeIds;
peerNodeIds.reserve(peerPositions.size());
for (auto const& entry : peerPositions)
peerNodeIds.push_back(entry.first);
recordParticipantDiagnostics(mode, std::move(peerNodeIds));
}
void
recordParticipantDiagnostics(
ConsensusMode mode,
std::vector<NodeID> peerNodeIds);
void
attachParticipantDiagnostics(ExtendedPosition& pos) const;
std::size_t
observedParticipantCount() const;
std::optional<uint256>
observedParticipantsHash() const;
std::string const&
observedParticipantsBitmapBin() const;
void
cacheConsensusTxSet(RCLTxSet const& txns);

View File

@@ -98,6 +98,27 @@ Be careful with `prevProposers`: in the generic consensus code it is peer-only.
When checking whether the previous round had enough active participants, count
our own proposer slot if this node is proposing.
## Participant Diagnostics
Proposals may carry a signed `observedParticipantsHash` for debugging active-UNL
visibility during RNG/Export rounds. The hash represents the active validators
this node has observed participating in the current establish round, including
self when proposing. It also commits to the active validator view used by the
node, so mismatched fallback/configured views do not collapse to the same
diagnostic hash merely because they have the same size.
Local diagnostics also log the same observed set as a canonical binary bitmap
over the sorted active validator view. The bitmap is not sent on the wire; the
signed proposal field remains the hash.
This field is diagnostic only:
- It is covered by the proposal signature and duplicate-suppression identity.
- It does not participate in `ExtendedPosition::operator==`.
- It does not lower the active validator quorum denominator.
- It is intended to explain timing/degraded-network cases where commits,
reveals, or sidecar hashes arrive late or asymmetrically.
## RNG Commit/Reveal Principles
RNG proceeds through establish sub-states:

View File

@@ -262,8 +262,10 @@ RCLConsensus::Adaptor::propose(RCLCxPeerPos::Proposal const& proposal)
wirePosition.exportSignaturesHash =
proposalExportSignaturesHash(prop.exportsignatures());
// Serialize full ExtendedPosition (includes RNG leaves and export
// signature digest)
ce().attachParticipantDiagnostics(wirePosition);
// Serialize full ExtendedPosition (includes RNG leaves, export signature
// digest, and signed diagnostics)
Serializer positionData;
wirePosition.add(positionData);
auto const posSlice = positionData.slice();

View File

@@ -59,6 +59,8 @@ struct ExtendedPosition
std::optional<uint256> entropySetHash;
std::optional<uint256> exportSigSetHash;
std::optional<uint256> exportSignaturesHash;
// Signed diagnostic only: not a quorum input and not part of operator==.
std::optional<uint256> observedParticipantsHash;
// === Per-Validator Leaves (unique per proposer) ===
std::optional<uint256> myCommitment;
@@ -166,7 +168,8 @@ struct ExtendedPosition
// Wire compatibility: if no extensions, emit exactly 32 bytes
// so legacy nodes that expect a plain uint256 work unchanged.
if (!commitSetHash && !entropySetHash && !exportSigSetHash &&
!exportSignaturesHash && !myCommitment && !myReveal)
!exportSignaturesHash && !observedParticipantsHash &&
!myCommitment && !myReveal)
return;
std::uint8_t flags = 0;
@@ -182,6 +185,8 @@ struct ExtendedPosition
flags |= 0x10;
if (exportSignaturesHash)
flags |= 0x20;
if (observedParticipantsHash)
flags |= 0x40;
s.add8(flags);
if (commitSetHash)
@@ -196,6 +201,8 @@ struct ExtendedPosition
s.addBitString(*exportSigSetHash);
if (exportSignaturesHash)
s.addBitString(*exportSignaturesHash);
if (observedParticipantsHash)
s.addBitString(*observedParticipantsHash);
}
//@@end rng-extended-position-serialize
@@ -212,6 +219,8 @@ struct ExtendedPosition
ret["export_sig_set"] = to_string(*exportSigSetHash);
if (exportSignaturesHash)
ret["export_signatures"] = to_string(*exportSignaturesHash);
if (observedParticipantsHash)
ret["observed_participants"] = to_string(*observedParticipantsHash);
return ret;
}
@@ -241,13 +250,13 @@ struct ExtendedPosition
std::uint8_t flags = sit.get8();
// Reject unknown flag bits (reduces wire malleability)
if (flags & 0xC0)
if (flags & 0x80)
return std::nullopt;
// Validate exact byte count for the flagged fields.
// Each flag bit indicates a 32-byte uint256.
int fieldCount = 0;
for (int i = 0; i < 6; ++i)
for (int i = 0; i < 7; ++i)
if (flags & (1 << i))
++fieldCount;
@@ -266,6 +275,8 @@ struct ExtendedPosition
pos.exportSigSetHash = sit.get256();
if (flags & 0x20)
pos.exportSignaturesHash = sit.get256();
if (flags & 0x40)
pos.observedParticipantsHash = sit.get256();
return pos;
}

View File

@@ -43,6 +43,7 @@ extensionsTick(Ext& ext, Ctx const& ctx)
// crash between commit and reveal.
bool const isRngEnabled = ext.rngEnabled();
bool const isExportEnabled = ext.exportEnabled();
JLOG(ext.j_.trace()) << "RNGGATE: phaseEstablish prevSeq="
<< (static_cast<std::uint32_t>(ctx.buildSeq) - 1)
@@ -52,6 +53,20 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " mode=" << to_string(ctx.mode)
<< " roundMs=" << ctx.roundTime.count();
if (isRngEnabled || isExportEnabled)
{
if constexpr (requires {
ext.recordParticipantDiagnostics(
ctx.mode, ctx.peerPositions);
})
{
// Diagnostic only: this records the active-UNL participants visible
// to this node so proposals can carry a signed hash for debugging
// timing/degraded-network cases. It is not a quorum denominator.
ext.recordParticipantDiagnostics(ctx.mode, ctx.peerPositions);
}
}
if (isRngEnabled)
{
auto const buildSeq = ctx.buildSeq;
@@ -112,6 +127,23 @@ extensionsTick(Ext& ext, Ctx const& ctx)
<< " minReveals=" << (minReveals ? "yes" : "no")
<< " anyReveals=" << (anyReveals ? "yes" : "no")
<< " likelyParticipants=" << std::to_string(likelyParticipants);
if constexpr (requires {
ext.observedParticipantCount();
ext.observedParticipantsHash();
ext.observedParticipantsBitmapBin();
})
{
auto const observedHash = ext.observedParticipantsHash();
JLOG(ext.j_.debug())
<< "STALLDIAG: participant-diagnostics"
<< " observedActiveParticipants="
<< ext.observedParticipantCount()
<< " observedParticipantsHash="
<< (observedHash ? to_string(*observedHash)
: std::string{"none"})
<< " bitmap=" << ext.observedParticipantsBitmapBin();
}
};
auto publishEntropySet = [&]() {
auto entropySetHash = ext.buildEntropySet(buildSeq);