From 03e0bb5fc314156458a2a0b2e1a9f9235b87cde2 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Thu, 21 May 2026 09:56:51 +0800 Subject: [PATCH] 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. --- src/test/consensus/ExtendedPosition_test.cpp | 43 +++++- .../app/consensus/ConsensusExtensions.cpp | 129 ++++++++++++++++++ src/xrpld/app/consensus/ConsensusExtensions.h | 34 +++++ .../consensus/ConsensusExtensionsDesign.md | 21 +++ src/xrpld/app/consensus/RCLConsensus.cpp | 6 +- src/xrpld/app/consensus/RCLCxPeerPos.h | 17 ++- src/xrpld/consensus/ConsensusExtensionsTick.h | 32 +++++ 7 files changed, 274 insertions(+), 8 deletions(-) diff --git a/src/test/consensus/ExtendedPosition_test.cpp b/src/test/consensus/ExtendedPosition_test.cpp index 0d04012c9..e237d57f0 100644 --- a/src/test/consensus/ExtendedPosition_test.cpp +++ b/src/test/consensus/ExtendedPosition_test.cpp @@ -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); diff --git a/src/xrpld/app/consensus/ConsensusExtensions.cpp b/src/xrpld/app/consensus/ConsensusExtensions.cpp index 861d1f392..b61113af6 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.cpp +++ b/src/xrpld/app/consensus/ConsensusExtensions.cpp @@ -59,6 +59,23 @@ ConsensusExtensions::ConsensusExtensions(Application& app, beast::Journal j) namespace { +std::string +buildObservedParticipantBitmap( + std::vector const& activeSorted, + hash_set 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 peerNodeIds) +{ + auto const view = activeValidatorView(); + hash_set 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 activeSorted( + view->nodeIds.begin(), view->nodeIds.end()); + std::sort(activeSorted.begin(), activeSorted.end()); + std::vector 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(view->size())); + for (auto const& nodeId : activeSorted) + s.addBitString(nodeId); + s.add32(static_cast(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 +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(pendingRevealCount()); rng["likely_participants"] = static_cast(expectedProposerCount()); + rng["observed_active_participants"] = + static_cast(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"); } diff --git a/src/xrpld/app/consensus/ConsensusExtensions.h b/src/xrpld/app/consensus/ConsensusExtensions.h index 3dce8a12d..38f9cb91a 100644 --- a/src/xrpld/app/consensus/ConsensusExtensions.h +++ b/src/xrpld/app/consensus/ConsensusExtensions.h @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace ripple { @@ -120,6 +121,9 @@ private: // Recent proposers intersected with the active UNL (liveness hint) hash_set likelyParticipants_; + std::optional 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 + void + recordParticipantDiagnostics( + ConsensusMode mode, + PeerPositions const& peerPositions) + { + std::vector 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 peerNodeIds); + + void + attachParticipantDiagnostics(ExtendedPosition& pos) const; + + std::size_t + observedParticipantCount() const; + + std::optional + observedParticipantsHash() const; + + std::string const& + observedParticipantsBitmapBin() const; + void cacheConsensusTxSet(RCLTxSet const& txns); diff --git a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md index 87c502fb4..9324a7f95 100644 --- a/src/xrpld/app/consensus/ConsensusExtensionsDesign.md +++ b/src/xrpld/app/consensus/ConsensusExtensionsDesign.md @@ -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: diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index 3be828a70..f00ce8d6a 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -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(); diff --git a/src/xrpld/app/consensus/RCLCxPeerPos.h b/src/xrpld/app/consensus/RCLCxPeerPos.h index f80d11197..6093ad4e3 100644 --- a/src/xrpld/app/consensus/RCLCxPeerPos.h +++ b/src/xrpld/app/consensus/RCLCxPeerPos.h @@ -59,6 +59,8 @@ struct ExtendedPosition std::optional entropySetHash; std::optional exportSigSetHash; std::optional exportSignaturesHash; + // Signed diagnostic only: not a quorum input and not part of operator==. + std::optional observedParticipantsHash; // === Per-Validator Leaves (unique per proposer) === std::optional 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; } diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index fc96b77bf..8070653ce 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -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(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);