test(rng): pin tier-2 threshold to pre-nUNL original view

tier2Threshold() anchors to originalViewSize (pre-nUNL), not the effective post-nUNL size(): nUNL shrinks the effective view while leaving faulty nodes in it, so a sub-quorum fraction of the effective view can exceed the Byzantine bound. A one-line regression to size() would relabel a forkable cohort as participant_aligned -- and no existing test caught it (the only nUNL test never calls tier2Threshold()/selectEntropy, and the only tier-2 selection test has no nUNL, so original == effective there).

Add testTier2ThresholdAnchorsToOriginalView: 8 active validators, 2 disabled via NegativeUNL (original 8, effective 6), asserting tier2Threshold()==5 (from the original 8) not 4 (from the effective 6), with quorum/gate cross-checks. Mutation-verified: flipping the production read to size() turns exactly this test red (tier2Threshold + entropyGateThreshold) while the existing tier-2 selection test stays green.

Also: pointer comment at tier2Threshold() linking the invariant to its guard test, and a design-doc fix -- the worked example used n=5, where the band is empty (quorum == participant_aligned == 4) so it illustrated an unreachable tier; now n=6.
This commit is contained in:
Nicholas Dudfield
2026-06-16 17:02:00 +07:00
parent b070785dee
commit 20d52d8b66
3 changed files with 79 additions and 7 deletions

View File

@@ -953,7 +953,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite
// The defining safety invariant, for EVERY view size: two aligned
// cohorts always share an honest validator, i.e. their overlap (2t - n)
// strictly exceeds the tolerated Byzantine count f = floor(n/5). And
// the Tier 2 bar is never stricter than the Tier 3 validator_quorum bar.
// the Tier 2 bar is never stricter than the Tier 3 validator_quorum
// bar.
for (std::size_t n = 1; n <= 256; ++n)
{
auto const t = calculateParticipantThreshold(n);
@@ -1033,7 +1034,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(zeroTx);
if (zeroTx)
{
// Tier 1 consensus_fallback digest over (prevLedgerHash, base set, seq).
// Tier 1 consensus_fallback digest over (prevLedgerHash, base set,
// seq).
auto const expectedFallback = sha512Half(
HashPrefix::entropyFallback,
ledger->info().hash,
@@ -1355,6 +1357,66 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(f.second == 0);
}
void
testTier2ThresholdAnchorsToOriginalView()
{
testcase(
"Tier 2 threshold anchors to pre-nUNL original view under "
"NegativeUNL");
using namespace jtx;
Env env{
*this,
envconfig(validator, ""),
supported_amendments() | featureConsensusEntropy |
featureNegativeUNL,
nullptr};
forceNonStandalone(env.app());
// Eight active validators with two disabled via NegativeUNL: the
// original-UNL denominator is 8 while the effective view is 6. The two
// anchor DIFFERENT participant thresholds, which is the whole reason
// Tier 2 keys off the original view:
// tier2Threshold(original 8) == 5 (the equivocation-safe floor)
// tier2Threshold(effective 6) == 4 (what a regression to size()
// gives)
// A 4-of-original-8 cohort has overlap 2*4 - 8 == 0, which does NOT
// exceed f = floor(8/5) = 1 -- labelling it participant_aligned would
// be forkable. Anchoring to the original view suppresses that mint;
// this test pins the anchor so a future refactor to size() fails
// loudly.
constexpr std::size_t kActive = 8;
constexpr std::size_t kDisabled = 2;
std::vector<PublicKey> activeKeys;
activeKeys.reserve(kActive);
for (std::size_t i = 0; i < kActive; ++i)
activeKeys.push_back(randomKeyPair(KeyType::secp256k1).first);
std::vector<PublicKey> const disabledKeys(
activeKeys.begin(), activeKeys.begin() + kDisabled);
auto const viewLedger =
makeUNLReportLedger(env, activeKeys, disabledKeys);
BEAST_EXPECT(viewLedger->rules().enabled(featureNegativeUNL));
ConsensusExtensions ce{env.app(), activeNoopJournal()};
ce.cacheUNLReport(viewLedger);
auto const view = ce.activeValidatorView();
BEAST_EXPECT(view->fromUNLReport);
BEAST_EXPECT(view->originalViewSize == kActive); // 8, pre-nUNL
BEAST_EXPECT(view->size() == kActive - kDisabled); // 6, effective
// The anchor. Tier 2 derives from originalViewSize (8 -> 5), NOT the
// effective size (6 -> 4); the gate is min(quorum, tier2) and quorum
// tracks the effective view (6 -> 5), so a regression to size() would
// drop both the floor and the gate to 4.
BEAST_EXPECT(calculateParticipantThreshold(kActive) == 5);
BEAST_EXPECT(calculateParticipantThreshold(kActive - kDisabled) == 4);
BEAST_EXPECT(ce.tier2Threshold() == 5);
BEAST_EXPECT(ce.quorumThreshold() == 5);
BEAST_EXPECT(ce.entropyGateThreshold() == 5);
}
void
testProposalProofRoundTrip()
{
@@ -2956,6 +3018,7 @@ public:
testOnPreBuildInjectsZeroEntropyFallback();
testOnPreBuildInjectsEntropySetEntropy();
testOnPreBuildTier2ParticipantAligned();
testTier2ThresholdAnchorsToOriginalView();
testProposalProofRoundTrip();
testHarvestRngDataReplacementAndRejection();
testExportSidecarBuildFetchAndMerge();

View File

@@ -244,6 +244,8 @@ ConsensusExtensions::tier2Threshold() const
// originalViewSize, not size(): nUNL can shrink the effective view while
// leaving faulty nodes in it, so a fraction of the effective view could
// exceed the Byzantine fraction (which is bounded over the original UNL).
// Regressing this to size() is a consensus fork under nUNL and is pinned by
// ConsensusExtensions_test::testTier2ThresholdAnchorsToOriginalView.
auto const base = activeValidatorView()->originalViewSize;
if (base == 0)
return 1; // safety: need at least one aligned participant

View File

@@ -171,13 +171,20 @@ If no entropy hash reaches the entropy gate threshold before the bounded
deadline, the round must fall back to the Tier 1 consensus-bound digest. This
is the safe degradation path, not a consensus failure.
Examples with five active validators, validator_quorum threshold four, and
participant_aligned threshold four:
Examples with six active validators, validator_quorum threshold five, and
participant_aligned threshold four (six is the smallest view with a non-empty
Tier 2 band — at five validators quorum and participant_aligned coincide at
four, leaving no band):
- Four honest validators align on one entropy hash and one validator advertises
- Five honest validators align on one entropy hash and one validator advertises
a bogus hash: proceed with validator_quorum entropy for the honest quorum.
- Two validators advertise different bogus hashes and only three align on the
honest hash: fall back to the Tier 1 digest.
- Four validators align on the honest hash and two advertise different bogus
hashes: proceed with participant_aligned (Tier 2) entropy — the aligned
cohort is below the 80% quorum but at or above the intersection-safe floor,
and its overlap (2*4 - 6 = 2 > floor(6/5) = 1) still shares an honest
validator between any two such cohorts.
- Three validators align on the honest hash and three fail to align: fall back
to the Tier 1 digest.
- No peer entropy hash is observed in time: fall back to the Tier 1 digest.
The fallback pseudo-transaction is deterministic — every node derives the same