From 03f195f5e47affd9dfd510a833464797c67ffb55 Mon Sep 17 00:00:00 2001 From: samliok Date: Tue, 15 Sep 2026 13:10:53 -0400 Subject: [PATCH 1/2] one response stored per validator --- nonvalidator/epochs.go | 46 ++++++++++++++++++------------------- nonvalidator/epochs_test.go | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/nonvalidator/epochs.go b/nonvalidator/epochs.go index 52e36485..6e0a5a05 100644 --- a/nonvalidator/epochs.go +++ b/nonvalidator/epochs.go @@ -145,18 +145,22 @@ type latestValidatorSetRetriever interface { type epochDigestCounter struct { logger common.Logger - // sealingBlockResponses stores sealing blocks that have been received by our non-validator. - // It maps the epoch they are creating, to the NodeIds that have sent them to us(and which digest they sent). + // sealingBlockResponses stores the latest sealing block each validator has sent us keyed by NodeID. // Once we have collected f+1 messages for a finalization, the sealing block for that epoch is validated. - sealingBlockResponses map[uint64]map[string]common.Digest + sealingBlockResponses map[string]sealingBlockResponse // latestValidatorSetRetriever is used to calculate the threshold of votes needed to validate an epoch latestValidatorSetRetriever latestValidatorSetRetriever } +type sealingBlockResponse struct { + epoch uint64 + digest common.Digest +} + func newEpochReplicator(logger common.Logger, validatorSetRetriever latestValidatorSetRetriever) *epochDigestCounter { return &epochDigestCounter{ - sealingBlockResponses: make(map[uint64]map[string]common.Digest), + sealingBlockResponses: make(map[string]sealingBlockResponse), logger: logger, latestValidatorSetRetriever: validatorSetRetriever, } @@ -179,25 +183,20 @@ func (e *epochDigestCounter) collectedSealingBlockInfo(sealingBlockInfo *common. e.logger.Debug("Collected a sealing block", zap.Stringer("QR", sealingBlockInfo), zap.Stringer("From", from)) - threshold := common.F(len(e.latestValidatorSetRetriever.Validators())) + 1 - newEpoch := bh.Seq - epochResponses, ok := e.sealingBlockResponses[newEpoch] - digest := bh.Digest - if !ok { - epochResponses = make(map[string]common.Digest) - e.sealingBlockResponses[newEpoch] = epochResponses - } - epochResponses[string(from)] = digest + threshold := common.F(len(validators)) + 1 + // the sequence number is the epoch the sealing block creates + response := sealingBlockResponse{epoch: bh.Seq, digest: bh.Digest} + e.sealingBlockResponses[string(from)] = response // check if we have a threshold of responses - counts := make(map[common.Digest]uint64) - for _, digest := range epochResponses { - count := counts[digest] + count := 0 + for _, other := range e.sealingBlockResponses { + if other != response { + continue + } count++ - counts[digest] = count - - if counts[digest] >= uint64(threshold) { - e.logger.Info("We received enough messages to validate a higher epoch", zap.Stringer("EpochInfo", sealingBlockInfo), zap.Int("Threshold", threshold), zap.Uint64("Responses", counts[digest])) + if count >= threshold { + e.logger.Info("We received enough messages to validate a higher epoch", zap.Stringer("EpochInfo", sealingBlockInfo), zap.Int("Threshold", threshold), zap.Int("Responses", count)) return true } } @@ -205,10 +204,11 @@ func (e *epochDigestCounter) collectedSealingBlockInfo(sealingBlockInfo *common. return false } +// removeOldEpochs deletes all responses for epochs strictly less than minEpochToKeep. func (e *epochDigestCounter) removeOldEpochs(minEpochToKeep uint64) { - for epoch := range e.sealingBlockResponses { - if epoch < minEpochToKeep { - delete(e.sealingBlockResponses, epoch) + for from, response := range e.sealingBlockResponses { + if response.epoch < minEpochToKeep { + delete(e.sealingBlockResponses, from) } } } diff --git a/nonvalidator/epochs_test.go b/nonvalidator/epochs_test.go index 40880991..57b6a6b9 100644 --- a/nonvalidator/epochs_test.go +++ b/nonvalidator/epochs_test.go @@ -297,3 +297,26 @@ func TestCollectedQuorumRound(t *testing.T) { }) } } + +// TestCollectedSealingBlockInfoOneResponsePerValidator asserts a validator's newest +// response replaces its earlier one, so a single validator can never reach the +// threshold alone and only its latest response counts toward it. +func TestCollectedSealingBlockInfoOneResponsePerValidator(t *testing.T) { + qr := newSealingQuorumRound(1, 4) + info := qr.Block.SealingBlockInfo() + validators := info.ValidatorSet + e := newEpochReplicator(testutil.MakeLogger(t, 1), &testValidatorSetRetriever{ + nodes: validators, + }) + + seq5 := newSealingTestBlock(5, 1, common.Digest{}, info).BlockHeader() + seq6 := newSealingTestBlock(6, 1, common.Digest{}, info).BlockHeader() + + // threshold is 2, one validator sending distinct sequences never reaches it + require.False(t, e.collectedSealingBlockInfo(info, seq5, validators[0].Id)) + require.False(t, e.collectedSealingBlockInfo(info, seq6, validators[0].Id)) + + // voters[0] moved on to seq 6, so seq 5 has one response + require.False(t, e.collectedSealingBlockInfo(info, seq5, validators[1].Id)) + require.True(t, e.collectedSealingBlockInfo(info, seq6, validators[1].Id)) +} From e014932f5eb0dc11d28832ced50e16c1f71716dc Mon Sep 17 00:00:00 2001 From: samliok Date: Tue, 15 Sep 2026 13:19:37 -0400 Subject: [PATCH 2/2] older sequences discarded --- nonvalidator/epochs.go | 4 ++++ nonvalidator/epochs_test.go | 10 +++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/nonvalidator/epochs.go b/nonvalidator/epochs.go index 6e0a5a05..102a4ff3 100644 --- a/nonvalidator/epochs.go +++ b/nonvalidator/epochs.go @@ -186,6 +186,10 @@ func (e *epochDigestCounter) collectedSealingBlockInfo(sealingBlockInfo *common. threshold := common.F(len(validators)) + 1 // the sequence number is the epoch the sealing block creates response := sealingBlockResponse{epoch: bh.Seq, digest: bh.Digest} + if stored, ok := e.sealingBlockResponses[string(from)]; ok && stored.epoch > response.epoch { + e.logger.Debug("Ignoring sealing block for a lower epoch than already collected from this node", zap.Stringer("From", from), zap.Uint64("Stored Epoch", stored.epoch), zap.Uint64("Epoch", response.epoch)) + return false + } e.sealingBlockResponses[string(from)] = response // check if we have a threshold of responses diff --git a/nonvalidator/epochs_test.go b/nonvalidator/epochs_test.go index 57b6a6b9..dc4a3ba9 100644 --- a/nonvalidator/epochs_test.go +++ b/nonvalidator/epochs_test.go @@ -298,9 +298,9 @@ func TestCollectedQuorumRound(t *testing.T) { } } -// TestCollectedSealingBlockInfoOneResponsePerValidator asserts a validator's newest -// response replaces its earlier one, so a single validator can never reach the -// threshold alone and only its latest response counts toward it. +// TestCollectedSealingBlockInfoOneResponsePerValidator asserts each validator counts +// toward one epoch, the highest it has sent, so a single validator can never reach +// the threshold and re-sending an older sealing block does not discard a newer one. func TestCollectedSealingBlockInfoOneResponsePerValidator(t *testing.T) { qr := newSealingQuorumRound(1, 4) info := qr.Block.SealingBlockInfo() @@ -313,10 +313,10 @@ func TestCollectedSealingBlockInfoOneResponsePerValidator(t *testing.T) { seq6 := newSealingTestBlock(6, 1, common.Digest{}, info).BlockHeader() // threshold is 2, one validator sending distinct sequences never reaches it - require.False(t, e.collectedSealingBlockInfo(info, seq5, validators[0].Id)) require.False(t, e.collectedSealingBlockInfo(info, seq6, validators[0].Id)) + require.False(t, e.collectedSealingBlockInfo(info, seq5, validators[0].Id)) - // voters[0] moved on to seq 6, so seq 5 has one response + // voters[0] still counts toward seq 6, not the older seq 5 require.False(t, e.collectedSealingBlockInfo(info, seq5, validators[1].Id)) require.True(t, e.collectedSealingBlockInfo(info, seq6, validators[1].Id)) }