From 49f05e4e478520d7e344811aa487709c85eb2c57 Mon Sep 17 00:00:00 2001 From: Nicholas Dudfield Date: Thu, 9 Apr 2026 17:51:51 +0700 Subject: [PATCH] fix(rng): require positive peer alignment for non-zero entropy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observation tick alone was insufficient — a node could pass the gate without any peer confirming its entropySetHash. Now the gate requires at least one tx-converged peer with a matching hash before accepting non-zero entropy. Three cases after the observation tick: 1. aligned > 0: peers confirm our hash → proceed with entropy 2. conflict: fetch/merge/rebuild → bounded wait → zero fallback 3. aligned=0, peersSeen=0: no peers published yet → bounded wait → zero fallback if still no peers at deadline 4. aligned=0, peersSeen>0: peers published but none match → zero Also: - CSF finalizeRoundEntropy now uses shouldZeroEntropy() (quorum check) - Two new TDD tests: - testRngNoEntropyWithoutPeerAlignment: healthy network must agree - testRngAlignmentRequiredForNonZeroEntropy: isolated peer must not produce non-zero entropy that differs from majority --- src/test/consensus/ConsensusRng_test.cpp | 116 ++++++++++++++++++ src/test/csf/Peer.h | 2 +- src/xrpld/consensus/ConsensusExtensionsTick.h | 42 ++++++- 3 files changed, 158 insertions(+), 2 deletions(-) diff --git a/src/test/consensus/ConsensusRng_test.cpp b/src/test/consensus/ConsensusRng_test.cpp index 759e92be9..8ca627967 100644 --- a/src/test/consensus/ConsensusRng_test.cpp +++ b/src/test/consensus/ConsensusRng_test.cpp @@ -729,6 +729,120 @@ public: } } + void + testRngNoEntropyWithoutPeerAlignment() + { + using namespace csf; + using namespace std::chrono; + + testcase("RNG no non-zero entropy without peer alignment"); + + // 5 peers. All peers see all reveals (healthy network). + // But peer 0 drops ALL incoming proposals after publishing + // its entropy set — simulating a node that publishes but + // never sees any peer's entropySetHash response. + // + // Without the alignment check, peer 0 would accept non-zero + // entropy based purely on its own local view (no peer + // confirmation). + // + // With the alignment check, peer 0 must see at least some + // peers agreeing on the same hash before accepting non-zero + // entropy. If it can't see any alignment within the bounded + // window, it must fall back to zero. + // + // The key invariant: no node should accept non-zero entropy + // unless it has observed positive peer agreement on the same + // entropySetHash. + + ConsensusParms const parms{}; + Sim sim; + + PeerGroup peers = sim.createGroup(5); + for (Peer* peer : peers) + peer->ce().enableRngConsensus_ = true; + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + // Warmup + sim.run(1); + BEAST_EXPECT(sim.synchronized(peers)); + + sim.run(3); + + // All peers should agree — either all have the same entropy + // (since all reveals are available), or some fall back to zero. + // The key check: no peer should have non-zero entropy that + // differs from the majority. + BEAST_EXPECT(sim.synchronized(peers)); + + auto const& refDigest = peers[0]->ce().lastEntropyDigest_; + for (Peer const* peer : peers) + BEAST_EXPECT(peer->ce().lastEntropyDigest_ == refDigest); + + // At least some peers should have non-zero entropy + // (healthy network, all reveals available) + BEAST_EXPECT(refDigest != uint256{}); + } + + void + testRngAlignmentRequiredForNonZeroEntropy() + { + using namespace csf; + using namespace std::chrono; + + testcase("RNG alignment required — isolated node falls back"); + + // 5 peers. Peer 0 is isolated after the warmup round: + // it can still propose but receives no proposals back. + // This means peer 0 publishes its entropySetHash but never + // sees any peer's entropySetHash — aligned=0, peersSeen=0. + // + // The alignment gate should prevent peer 0 from accepting + // non-zero entropy without peer confirmation. Instead it + // should fall back to zero or desync. + + ConsensusParms const parms{}; + Sim sim; + + PeerGroup peers = sim.createGroup(5); + for (Peer* peer : peers) + peer->ce().enableRngConsensus_ = true; + + peers.trustAndConnect( + peers, round(0.2 * parms.ledgerGRANULARITY)); + + // Warmup + sim.run(1); + BEAST_EXPECT(sim.synchronized(peers)); + + // Isolate peer 0: drop all reveals from it so its + // entropy set will differ, AND it won't see peer alignment + // because its entropy hash won't match anyone else's. + for (std::size_t i = 1; i < peers.size(); ++i) + peers[0]->ce().dropRevealFrom_.insert(peers[i]->id); + + sim.run(3); + + // The majority (peers 1-4) should agree on non-zero entropy + std::vector majority; + for (std::size_t i = 1; i < peers.size(); ++i) + majority.push_back(peers[i]); + + auto const& majorityDigest = majority[0]->ce().lastEntropyDigest_; + BEAST_EXPECT(majorityDigest != uint256{}); + for (Peer const* peer : majority) + BEAST_EXPECT(peer->ce().lastEntropyDigest_ == majorityDigest); + + // Peer 0 must NOT have non-zero entropy that differs from + // the majority. It should either: + // a) have converged to the majority via fetch/merge, or + // b) have fallen back to zero entropy + auto const& p0Digest = peers[0]->ce().lastEntropyDigest_; + BEAST_EXPECT(p0Digest == majorityDigest || p0Digest == uint256{}); + } + void run() override { @@ -759,6 +873,8 @@ public: RUN(testRngEntropyConvergesWithPartialReveals); RUN(testRngEntropyFallbackOnMajorRevealLoss); RUN(testRngSingleByzantineCannotDenyEntropy); + RUN(testRngNoEntropyWithoutPeerAlignment); + RUN(testRngAlignmentRequiredForNonZeroEntropy); #undef RUN } diff --git a/src/test/csf/Peer.h b/src/test/csf/Peer.h index c7cf90d7d..b04ec628a 100644 --- a/src/test/csf/Peer.h +++ b/src/test/csf/Peer.h @@ -653,7 +653,7 @@ struct Peer << " entropyFailed=" << (entropyFailed_ ? "yes" : "no") << " reveals=" << pendingReveals_.size(); - if (entropyFailed_ || pendingReveals_.empty()) + if (shouldZeroEntropy()) { lastEntropyDigest_.zero(); lastEntropyCount_ = 0; diff --git a/src/xrpld/consensus/ConsensusExtensionsTick.h b/src/xrpld/consensus/ConsensusExtensionsTick.h index 5c54a5c96..f1340ce9f 100644 --- a/src/xrpld/consensus/ConsensusExtensionsTick.h +++ b/src/xrpld/consensus/ConsensusExtensionsTick.h @@ -604,8 +604,48 @@ extensionsTick(Ext& ext, Ctx const& ctx) logRngDiag("rng-entropy-hash-conflict-timeout"); } + // Positive alignment check: require at least one + // tx-converged peer with a matching entropySetHash + // before accepting non-zero entropy. Without this, + // a node could accept based purely on its local view + // with no peer confirmation. + if (!conflict && aligned == 0 && peersSeen == 0) + { + // No peers have published an entropySetHash yet. + // Wait for the bounded window so they have time. + auto const entropyElapsed = + ctx.nowSteady - ext.revealPhaseStart_; + auto const entropyDeadline = + ctx.parms.rngREVEAL_TIMEOUT * 2; + if (entropyElapsed <= entropyDeadline) + { + JLOG(ext.j_.debug()) + << "RNG: waiting for peer entropySetHash " + "alignment (none seen yet)"; + logRngDiag("rng-entropy-hash-no-peers-wait"); + return {}; + } + // Deadline: no peers ever published. Fall back. + ext.setEntropyFailed(); + JLOG(ext.j_.warn()) + << "RNG: no peer entropySetHash observed " + "within deadline, falling back to zero"; + logRngDiag("rng-entropy-hash-no-peers-timeout"); + } + else if (!conflict && aligned == 0 && peersSeen > 0) + { + // Peers published but none match ours — this + // shouldn't happen after merge, but treat as + // conflict and fall back. + ext.setEntropyFailed(); + JLOG(ext.j_.warn()) + << "RNG: peers published entropySetHash but " + "none align with ours, falling back"; + logRngDiag("rng-entropy-hash-no-alignment"); + } + JLOG(ext.j_.debug()) - << "RNG: entropy gate passed — aligned=" << aligned + << "RNG: entropy gate — aligned=" << aligned << " peersSeen=" << peersSeen << " conflict=" << (conflict ? "yes" : "no"); }