fix(rng): scope sidecar alignment count to the active validator view (F1)

The entropy/export bless-vs-fallback gate counted alignment over ALL tx-converged trusted proposers (currPeerPositions_), while the thresholds and the entropy leaf set are computed over the active validator view. A node that locally trusts proposers outside the on-ledger UNLReport active set could pad alignedParticipants(), inflating the counting universe N above originalViewSize and eroding the Tier-2 intersection margin (2t - N) below the Byzantine floor f -- so two equivocation cohorts padded by non-active aligners could each clear the gate (review finding F1). Backstopped by the 80% validation quorum, but the proof's universe and the code's universe must match.

inspectTxConvergedSidecarPeers now takes an active-view membership predicate and counts only member peers toward alignment; the local +1 is gated on localIsActiveValidator(). Both the RNG entropy gate and the export-sig gate pass ext.isUNLReportMember / ext.localIsActiveValidator, mirroring buildEntropySet / hasQuorumOfCommits' containsNode filter. New method on ConsensusExtensions + CSF Peer + the FakeExtensions stub.

Regression test (Sidecar peer alignment helper): a trusted-but-non-active aligned proposer pushes unfiltered aligned to 2 but filtered aligned stays 1, and a non-active local node's +1 is suppressed -- padding cannot satisfy the gate. ConsensusExtensions 900, ConsensusRng 386, Consensus 1399, all green.

Note: the explicit-final proposal path (ConsensusExtensionsTick.h ~:934) counts over prevProposers and is NOT yet filtered (separate, experimental path).
This commit is contained in:
Nicholas Dudfield
2026-06-18 09:44:56 +07:00
parent c8fad50d66
commit fb9e2710cc
5 changed files with 123 additions and 5 deletions

View File

@@ -397,6 +397,22 @@ struct FakeExtensions
return exportQuorum;
}
// Membership is a no-op in the FakeExtensions tick tests (every peer
// counts, local counts) so the gate behavior is unchanged; F1 active-view
// filtering is exercised directly against inspectTxConvergedSidecarPeers.
template <class Id>
bool
isUNLReportMember(Id const&) const
{
return true;
}
bool
localIsActiveValidator() const
{
return true;
}
std::size_t
pendingCommitCount() const
{
@@ -777,11 +793,18 @@ class ConsensusExtensions_test : public beast::unit_test::suite
harness.addPeer(3, std::nullopt);
harness.addPeer(4, localHash, makeHash("other-tx-set"));
auto const exportHashOf = [](auto const& position) {
return position.exportSigSetHash;
};
auto const allMembers = [](auto const&) { return true; };
std::vector<uint256> fetched;
auto const state = detail::inspectTxConvergedSidecarPeers(
harness.peers,
harness.position,
[](auto const& position) { return position.exportSigSetHash; },
true,
exportHashOf,
allMembers,
[&](auto const& hash) {
if (hash)
fetched.push_back(*hash);
@@ -804,12 +827,56 @@ class ConsensusExtensions_test : public beast::unit_test::suite
auto const unpublishedState = detail::inspectTxConvergedSidecarPeers(
harness.peers,
harness.position,
[](auto const& position) { return position.exportSigSetHash; },
true,
exportHashOf,
allMembers,
[](auto const&) {});
BEAST_EXPECT(!unpublishedState.localPublished);
BEAST_EXPECT(unpublishedState.alignedParticipants() == 0);
BEAST_EXPECT(!unpublishedState.quorumAligned(1));
BEAST_EXPECT(unpublishedState.fullObservation());
// F1: the alignment-counting universe must be the active validator
// view, not the full trusted-proposer set. A trusted-but-non-active
// proposer (node 5) that tx-converges and aligns on the SAME hash must
// NOT inflate alignedParticipants() — otherwise two equivocation
// cohorts padded by non-active peers could each clear the gate.
harness.position.exportSigSetHash = localHash;
harness.addPeer(5, localHash); // trusted, but outside the active view
auto const activeOnly = [](auto const& id) {
return id != makeNode(5); // nodes 1..4 active; 5 is not
};
auto const padded = detail::inspectTxConvergedSidecarPeers(
harness.peers,
harness.position,
true,
exportHashOf,
allMembers,
[](auto const&) {});
BEAST_EXPECT(padded.aligned == 2); // node 1 + node 5, unfiltered
auto const filtered = detail::inspectTxConvergedSidecarPeers(
harness.peers,
harness.position,
true,
exportHashOf,
activeOnly,
[](auto const&) {});
BEAST_EXPECT(filtered.aligned == 1); // node 5 excluded
BEAST_EXPECT(filtered.alignedParticipants() == 2); // node 1 + local
BEAST_EXPECT(!filtered.quorumAligned(3)); // padding can't reach quorum
// The local +1 is likewise gated on local active-view membership.
auto const nonActiveLocal = detail::inspectTxConvergedSidecarPeers(
harness.peers,
harness.position,
false,
exportHashOf,
activeOnly,
[](auto const&) {});
BEAST_EXPECT(!nonActiveLocal.localPublished);
BEAST_EXPECT(nonActiveLocal.alignedParticipants() == 1); // node 1 only
}
void

View File

@@ -721,6 +721,12 @@ struct Peer
return unlNodes_.count(nodeId) > 0;
}
bool
localIsActiveValidator() const
{
return isUNLReportMember(peer.id);
}
void
finalizeRoundEntropy(
std::uint32_t seq,

View File

@@ -987,6 +987,18 @@ ConsensusExtensions::isUNLReportMember(NodeID const& nodeId) const
return activeValidatorView()->containsNode(nodeId);
}
bool
ConsensusExtensions::localIsActiveValidator() const
{
// Our own sidecar position only counts toward alignment when this validator
// is itself in the active view — the same universe as the peer-membership
// filter and the entropy/export thresholds.
auto const& valKeys = app_.getValidatorKeys();
if (!valKeys.keys || valKeys.nodeID == beast::zero)
return false;
return activeValidatorView()->containsNode(valKeys.nodeID);
}
ConsensusExtensions::ActiveValidatorViewPtr
ConsensusExtensions::activeValidatorView() const
{

View File

@@ -318,6 +318,12 @@ public:
bool
isUNLReportMember(NodeID const& nodeId) const;
// True only when THIS node's own validator key is in the active view. Used
// to gate our own +1 in the sidecar alignment count to the same universe as
// the peer-membership filter and the entropy/export thresholds.
bool
localIsActiveValidator() const;
void
generateEntropySecret();

View File

@@ -37,12 +37,19 @@ struct SidecarPeerAlignment
}
};
template <class PeerPositions, class Position, class GetHash, class OnMismatch>
template <
class PeerPositions,
class Position,
class GetHash,
class IsMember,
class OnMismatch>
SidecarPeerAlignment
inspectTxConvergedSidecarPeers(
PeerPositions const& peerPositions,
Position const& pos,
bool localIsMember,
GetHash getHash,
IsMember isMember,
OnMismatch onMismatch)
{
SidecarPeerAlignment state;
@@ -50,9 +57,21 @@ inspectTxConvergedSidecarPeers(
if (!localHash)
return state;
state.localPublished = true;
for (auto const& [_, peerPos] : peerPositions)
// The alignment-counting universe must be the active validator view, the
// same denominator the entropy/export thresholds use (quorumThreshold /
// tier2Threshold are computed over that view). A trusted-but-non-active
// proposer can tx-converge and advertise a sidecar hash, but it must NOT
// pad alignedParticipants(): counting outside the active view inflates the
// universe N above originalViewSize and erodes the Tier-2 intersection
// margin (2t - N) below the Byzantine floor f, breaking equivocation
// uniqueness. Mirror buildEntropySet/hasQuorumOfCommits' containsNode
// filter, and only count our own +1 when this node is itself active.
state.localPublished = localIsMember;
for (auto const& [nodeId, peerPos] : peerPositions)
{
if (!isMember(nodeId))
continue; // outside the active view -> not in the counting
// universe
auto const& pp = peerPos.proposal().position();
if (!(pp == pos))
continue; // not tx-converged
@@ -705,9 +724,13 @@ extensionsTick(Ext& ext, Ctx const& ctx)
return detail::inspectTxConvergedSidecarPeers(
ctx.peerPositions,
pos,
ext.localIsActiveValidator(),
[](auto const& position) {
return position.entropySetHash;
},
[&ext](auto const& nodeId) {
return ext.isUNLReportMember(nodeId);
},
[&](auto const& hash) {
if (fetchMismatches)
ext.fetchRngSetIfNeeded(
@@ -1210,9 +1233,13 @@ extensionsTick(Ext& ext, Ctx const& ctx)
return detail::inspectTxConvergedSidecarPeers(
ctx.peerPositions,
pos,
ext.localIsActiveValidator(),
[](auto const& position) {
return position.exportSigSetHash;
},
[&ext](auto const& nodeId) {
return ext.isUNLReportMember(nodeId);
},
[&](auto const& hash) {
if (fetchMismatches)
ext.fetchRngSetIfNeeded(