fix(rng): require positive peer alignment for non-zero entropy

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
This commit is contained in:
Nicholas Dudfield
2026-04-09 17:51:51 +07:00
parent 1f51b9c594
commit 49f05e4e47
3 changed files with 158 additions and 2 deletions

View File

@@ -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<milliseconds>(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<milliseconds>(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<Peer const*> 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
}

View File

@@ -653,7 +653,7 @@ struct Peer
<< " entropyFailed=" << (entropyFailed_ ? "yes" : "no")
<< " reveals=" << pendingReveals_.size();
if (entropyFailed_ || pendingReveals_.empty())
if (shouldZeroEntropy())
{
lastEntropyDigest_.zero();
lastEntropyCount_ = 0;

View File

@@ -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");
}