@@ -112,6 +112,24 @@ networkEnjoysQuorumIntersectionV2Wrapper(QuorumTracker::QuorumMap const& qmap,
112112 return state->mStatus == QuorumCheckerStatus::UNSAT ;
113113}
114114
115+ // Like networkEnjoysQuorumIntersectionV2Wrapper, but returns the raw checker
116+ // status so tests can distinguish UNSAT (enjoys intersection) from NO_QUORUM
117+ // (the FBAS admits no quorum at all -- a halted/degenerate configuration).
118+ QuorumCheckerStatus
119+ quorumCheckStatusV2Wrapper (QuorumTracker::QuorumMap const & qmap,
120+ Config const & cfg)
121+ {
122+ VirtualClock clock;
123+ Application::pointer app = createTestApplication (clock, cfg);
124+ auto state = std::make_shared<QuorumMapIntersectionState>(*app);
125+ state->mRecalculating = true ;
126+ quorumIntersectionCheckerV2Wrapper (
127+ *app, state, qmap, app->getProcessManager (), clock, false ,
128+ cfg.QUORUM_INTERSECTION_CHECKER_TIME_LIMIT_MS ,
129+ cfg.QUORUM_INTERSECTION_CHECKER_MEMORY_LIMIT_BYTES );
130+ return state->mStatus ;
131+ }
132+
115133std::set<std::set<NodeID>>
116134runIntersectionCriticalGroupsCheckV2 (QuorumTracker::QuorumMap const & qmap,
117135 Config const & cfg)
@@ -237,6 +255,58 @@ TEST_CASE("quorum non intersection basic 4-node",
237255 REQUIRE (!networkEnjoysQuorumIntersectionV2Wrapper (qm, cfg));
238256}
239257
258+ TEST_CASE (" quorum no-quorum threshold exceeds degree" ,
259+ " [herder][quorumintersection]" )
260+ {
261+ QuorumTracker::QuorumMap qm;
262+
263+ PublicKey pkA = SecretKey::pseudoRandomForTesting ().getPublicKey ();
264+ PublicKey pkB = SecretKey::pseudoRandomForTesting ().getPublicKey ();
265+ PublicKey pkC = SecretKey::pseudoRandomForTesting ().getPublicKey ();
266+ PublicKey pkD = SecretKey::pseudoRandomForTesting ().getPublicKey ();
267+ // A "ghost" validator that is referenced by others' quorum sets but is
268+ // itself absent from the quorum map, so it can never be available. With a
269+ // threshold of 3 over {self, peer, ghost}, no node can ever reach its
270+ // threshold, so the FBAS admits no quorum at all. This must be reported as
271+ // NO_QUORUM, and is distinct from UNSAT ("enjoys quorum intersection").
272+ PublicKey pkGhost = SecretKey::pseudoRandomForTesting ().getPublicKey ();
273+
274+ qm[pkA] = QuorumTracker::NodeInfo{
275+ make_shared<QS >(3 , VK ({pkA, pkB, pkGhost}), VQ {}), 0 };
276+ qm[pkB] = QuorumTracker::NodeInfo{
277+ make_shared<QS >(3 , VK ({pkA, pkB, pkGhost}), VQ {}), 0 };
278+ qm[pkC] = QuorumTracker::NodeInfo{
279+ make_shared<QS >(3 , VK ({pkC, pkD, pkGhost}), VQ {}), 0 };
280+ qm[pkD] = QuorumTracker::NodeInfo{
281+ make_shared<QS >(3 , VK ({pkC, pkD, pkGhost}), VQ {}), 0 };
282+
283+ Config cfg (getTestConfig ());
284+
285+ VirtualClock clock;
286+ Application::pointer app = createTestApplication (clock, cfg);
287+ auto state = std::make_shared<QuorumMapIntersectionState>(*app);
288+
289+ auto & noQuorumCounter =
290+ state->mMetrics .NewCounter ({" scp" , " qic" , " result-no-quorum" });
291+ auto noQuorumCountBefore = noQuorumCounter.count ();
292+
293+ state->mRecalculating = true ;
294+ state->mStatus = QuorumCheckerStatus::UNKNOWN ;
295+ quorumIntersectionCheckerV2Wrapper (
296+ *app, state, qm, app->getProcessManager (), clock, false ,
297+ cfg.QUORUM_INTERSECTION_CHECKER_TIME_LIMIT_MS ,
298+ cfg.QUORUM_INTERSECTION_CHECKER_MEMORY_LIMIT_BYTES );
299+
300+ REQUIRE (state->mStatus == QuorumCheckerStatus::NO_QUORUM );
301+ REQUIRE (noQuorumCounter.count () == noQuorumCountBefore + 1 );
302+ // NO_QUORUM is a complete result (records the checked ledger) but it is not
303+ // a "good" result, so it does not advance mLastGoodLedger and the network
304+ // does not enjoy quorum intersection.
305+ REQUIRE (state->mLastCheckLedger != 0 );
306+ REQUIRE (state->mLastGoodLedger == 0 );
307+ REQUIRE (!state->enjoysQuorunIntersection ());
308+ }
309+
240310TEST_CASE (" quorum non intersection 6-node" , " [herder][quorumintersection]" )
241311{
242312 QuorumTracker::QuorumMap qm;
@@ -956,8 +1026,13 @@ TEST_CASE("quorum intersection 6-org 1-node 4-null qsets",
9561026 // for org2..org5. We know org0..org1 have threshold 67% = 3-of-4 (4 being
9571027 // "self + 3 neighbours"); the current logic in the quorum intersection
9581028 // checker (see buildGraph and convertSCPQuorumSet) will treat this network
959- // as _only_ having 2-nodes and will therefore declare it vacuously enjoying
960- // quorum intersection due to being halted.
1029+ // as _only_ having 2-nodes and therefore as a halted network with no
1030+ // quorum.
1031+ //
1032+ // The V1 checker declares such a halted network as vacuously enjoying
1033+ // quorum intersection; the V2 checker instead reports NO_QUORUM (the FBAS
1034+ // admits no quorum at all), which is not conflated with UNSAT ("enjoys
1035+ // intersection").
9611036 //
9621037 // (At other points in the design, and possibly again in the future if we
9631038 // change our minds, we modeled this differently, treating the null-qset
@@ -991,7 +1066,10 @@ TEST_CASE("quorum intersection 6-org 1-node 4-null qsets",
9911066 REQUIRE (qic->networkEnjoysQuorumIntersection ());
9921067 REQUIRE (qic->getMaxQuorumsFound () == 0 );
9931068
994- REQUIRE (networkEnjoysQuorumIntersectionV2Wrapper (qm, cfg));
1069+ // V2 reports the halted network as having no quorum, rather than vacuously
1070+ // enjoying quorum intersection like V1.
1071+ REQUIRE (quorumCheckStatusV2Wrapper (qm, cfg) ==
1072+ QuorumCheckerStatus::NO_QUORUM );
9951073}
9961074
9971075TEST_CASE (" quorum intersection 4-org 1-node 4-null qsets" ,
@@ -1008,10 +1086,11 @@ TEST_CASE("quorum intersection 4-org 1-node 4-null qsets",
10081086 // +-> org3 <-+
10091087 //
10101088 // As with the case before, this represents (to the quorum intersection
1011- // checker's eyes) a halted network which vacuously enjoys quorum
1012- // intersection. But if we were using one of the other models for the
1013- // meaning of a null qset, it might be different: split in the byzantine
1014- // case, live and enjoying quorum intersection in the live-and-unknown case.
1089+ // checker's eyes) a halted network with no quorum. The V1 checker treats it
1090+ // as vacuously enjoying quorum intersection, while the V2 checker reports
1091+ // NO_QUORUM. But if we were using one of the other models for the meaning
1092+ // of a null qset, it might be different: split in the byzantine case, live
1093+ // and enjoying quorum intersection in the live-and-unknown case.
10151094
10161095 auto orgs = generateOrgs (4 , {1 });
10171096 auto qm = interconnectOrgsUnidir (orgs, {
@@ -1040,7 +1119,10 @@ TEST_CASE("quorum intersection 4-org 1-node 4-null qsets",
10401119 REQUIRE (qic->networkEnjoysQuorumIntersection ());
10411120 REQUIRE (qic->getMaxQuorumsFound () == 0 );
10421121
1043- REQUIRE (networkEnjoysQuorumIntersectionV2Wrapper (qm, cfg));
1122+ // V2 reports the halted network as having no quorum, rather than vacuously
1123+ // enjoying quorum intersection like V1.
1124+ REQUIRE (quorumCheckStatusV2Wrapper (qm, cfg) ==
1125+ QuorumCheckerStatus::NO_QUORUM );
10441126}
10451127
10461128TEST_CASE (" quorum intersection 6-org 3-node fully-connected" ,
0 commit comments