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 // The defining safety invariant, for EVERY view size: two aligned
// cohorts always share an honest validator, i.e. their overlap (2t - n) // cohorts always share an honest validator, i.e. their overlap (2t - n)
// strictly exceeds the tolerated Byzantine count f = floor(n/5). And // 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) for (std::size_t n = 1; n <= 256; ++n)
{ {
auto const t = calculateParticipantThreshold(n); auto const t = calculateParticipantThreshold(n);
@@ -1033,7 +1034,8 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(zeroTx); BEAST_EXPECT(zeroTx);
if (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( auto const expectedFallback = sha512Half(
HashPrefix::entropyFallback, HashPrefix::entropyFallback,
ledger->info().hash, ledger->info().hash,
@@ -1355,6 +1357,66 @@ class ConsensusExtensions_test : public beast::unit_test::suite
BEAST_EXPECT(f.second == 0); 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 void
testProposalProofRoundTrip() testProposalProofRoundTrip()
{ {
@@ -2956,6 +3018,7 @@ public:
testOnPreBuildInjectsZeroEntropyFallback(); testOnPreBuildInjectsZeroEntropyFallback();
testOnPreBuildInjectsEntropySetEntropy(); testOnPreBuildInjectsEntropySetEntropy();
testOnPreBuildTier2ParticipantAligned(); testOnPreBuildTier2ParticipantAligned();
testTier2ThresholdAnchorsToOriginalView();
testProposalProofRoundTrip(); testProposalProofRoundTrip();
testHarvestRngDataReplacementAndRejection(); testHarvestRngDataReplacementAndRejection();
testExportSidecarBuildFetchAndMerge(); testExportSidecarBuildFetchAndMerge();

View File

@@ -244,6 +244,8 @@ ConsensusExtensions::tier2Threshold() const
// originalViewSize, not size(): nUNL can shrink the effective view while // originalViewSize, not size(): nUNL can shrink the effective view while
// leaving faulty nodes in it, so a fraction of the effective view could // leaving faulty nodes in it, so a fraction of the effective view could
// exceed the Byzantine fraction (which is bounded over the original UNL). // 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; auto const base = activeValidatorView()->originalViewSize;
if (base == 0) if (base == 0)
return 1; // safety: need at least one aligned participant 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 deadline, the round must fall back to the Tier 1 consensus-bound digest. This
is the safe degradation path, not a consensus failure. is the safe degradation path, not a consensus failure.
Examples with five active validators, validator_quorum threshold four, and Examples with six active validators, validator_quorum threshold five, and
participant_aligned threshold four: 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. a bogus hash: proceed with validator_quorum entropy for the honest quorum.
- Two validators advertise different bogus hashes and only three align on the - Four validators align on the honest hash and two advertise different bogus
honest hash: fall back to the Tier 1 digest. 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. - 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 The fallback pseudo-transaction is deterministic — every node derives the same