Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/abci.go
Original file line number Diff line number Diff line change
Expand Up @@ -961,7 +961,7 @@ func (app *HeimdallApp) checkAndAddFutureSpan(ctx sdk.Context, majorityMilestone
return err
}

err = app.BorKeeper.AddNewVeBlopSpan(ctx, currentProducer, lastSpan.EndBlock+1, endBlock, lastSpan.BorChainId, supportingValidatorIDs, uint64(ctx.BlockHeight()), borTypes.RoundRobinDefault, nil)
err = app.BorKeeper.AddNewVeBlopSpan(ctx, currentProducer, lastSpan.EndBlock+1, endBlock, lastSpan.BorChainId, supportingValidatorIDs, uint64(ctx.BlockHeight()), borTypes.RoundRobinDefault, nil, true)
if err != nil {
logger.Error("Error occurred while adding new veblop span", "error", err)
return err
Expand Down Expand Up @@ -1069,7 +1069,7 @@ func (app *HeimdallApp) checkAndRotateCurrentSpan(ctx sdk.Context) error {

delete(latestActiveProducer, currentProducer)

err = app.BorKeeper.AddNewVeBlopSpan(addSpanCtx, currentProducer, lastMilestone.EndBlock+1, endBlock, lastMilestone.BorChainId, latestActiveProducer, uint64(ctx.BlockHeight()), borTypes.RoundRobinDefault, nil)
err = app.BorKeeper.AddNewVeBlopSpan(addSpanCtx, currentProducer, lastMilestone.EndBlock+1, endBlock, lastMilestone.BorChainId, latestActiveProducer, uint64(ctx.BlockHeight()), borTypes.RoundRobinDefault, nil, false)
if err != nil {
logger.Warn("Error occurred while adding new veblop span", "error", err)
} else {
Expand Down
2 changes: 1 addition & 1 deletion app/pending_stall.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func (app *HeimdallApp) rotateSpanFromPendingHead(ctx sdk.Context, pendingHead u
return err
}

if err := app.BorKeeper.AddNewVeBlopSpan(addSpanCtx, currentProducer, pendingHead+1, endBlock, lastSpan.BorChainId, latestActiveProducer, uint64(ctx.BlockHeight()), borTypes.RoundRobinDefault, excludedProducers); err != nil {
if err := app.BorKeeper.AddNewVeBlopSpan(addSpanCtx, currentProducer, pendingHead+1, endBlock, lastSpan.BorChainId, latestActiveProducer, uint64(ctx.BlockHeight()), borTypes.RoundRobinDefault, excludedProducers, false); err != nil {
// Don't halt consensus if no producer can be selected this block; retry next block.
logger.Warn("Error occurred while adding new veblop span on pending stall", "error", err)
return nil
Expand Down
217 changes: 217 additions & 0 deletions app/veblop_producer_fallback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package app

import (
"strings"
"testing"

abci "github.com/cometbft/cometbft/abci/types"
cmtsecp256k1 "github.com/cometbft/cometbft/crypto/secp256k1"
cmtproto "github.com/cometbft/cometbft/proto/tendermint/types"
sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"

"github.com/0xPolygon/heimdall-v2/helper"
"github.com/0xPolygon/heimdall-v2/sidetxs"
borKeeper "github.com/0xPolygon/heimdall-v2/x/bor/keeper"
borTypes "github.com/0xPolygon/heimdall-v2/x/bor/types"
milestoneTypes "github.com/0xPolygon/heimdall-v2/x/milestone/types"
stakeTypes "github.com/0xPolygon/heimdall-v2/x/stake/types"
)

// singletonScenario holds the staged degenerate VEBLOP state: the elected producer set
// has collapsed to the single current producer, and a >2/3 milestone is finalized by the
// other validators while the current producer withholds its milestone vote (so it is
// excluded from the supporting set). Future-span creation in PreBlocker then asks
// SelectNextSpanProducer for a replacement and the candidate set (singleton minus the
// current producer) is empty.
type singletonScenario struct {
app *HeimdallApp
ctx sdk.Context
height int64
lastSpan borTypes.Span
currentProducerID uint64
validators []*stakeTypes.Validator
extCommitBytes []byte
}

func stageSingletonProducer(t *testing.T) *singletonScenario {
t.Helper()
_, app, ctx, validatorPrivKeys := SetupAppWithABCICtxAndValidators(t, 4)

oldRio := helper.GetRioHeight()
helper.SetRioHeight(100)
t.Cleanup(func() { helper.SetRioHeight(oldRio) })

Comment thread
pratikspatil024 marked this conversation as resolved.
height := app.LastBlockHeight() + 1
ctx = ctx.WithBlockHeight(height)

validators := app.StakeKeeper.GetAllValidators(ctx)
require.Len(t, validators, 4)

currentProducerID := validators[0].ValId
lastSpan := borTypes.Span{
Id: 1,
StartBlock: 100,
EndBlock: 199,
BorChainId: "1",
ValidatorSet: stakeTypes.ValidatorSet{Validators: validators, Proposer: validators[0]},
SelectedProducers: []stakeTypes.Validator{*validators[0]},
}
require.NoError(t, app.BorKeeper.AddNewSpan(ctx, &lastSpan))

// Every validator casts a one-entry ballot for the current producer, collapsing
// CalculateProducerSet to the singleton [currentProducer].
msgServer := borKeeper.NewMsgServerImpl(app.BorKeeper)
for _, validator := range validators {
_, err := msgServer.VoteProducers(ctx, &borTypes.MsgVoteProducers{
Voter: validator.Signer,
VoterId: validator.ValId,
Votes: borTypes.ProducerVotes{Votes: []uint64{currentProducerID}},
})
require.NoError(t, err)
}
candidates, err := app.BorKeeper.CalculateProducerSet(ctx, helper.GetProducerSetLimit(ctx))
require.NoError(t, err)
require.Equal(t, []uint64{currentProducerID}, candidates)

previousMilestoneHash := common.HexToHash("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Bytes()
require.NoError(t, app.MilestoneKeeper.AddMilestone(ctx, milestoneTypes.Milestone{
Proposer: validators[1].Signer,
Hash: previousMilestoneHash,
StartBlock: 1,
EndBlock: lastSpan.StartBlock - 1,
BorChainId: "1",
MilestoneId: "previous",
Timestamp: 1,
TotalDifficulty: 1,
}))

winningMilestone := &milestoneTypes.MilestoneProposition{
StartBlockNumber: lastSpan.StartBlock,
ParentHash: previousMilestoneHash,
BlockHashes: [][]byte{common.HexToHash("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Bytes()},
BlockTds: []uint64{2},
}

// Validators 1, 2 and 3 (75% of voting power) finalize the milestone; the current
// producer (validator 0) withholds, so it is absent from supportingValidatorIDs.
extCommitBytes := milestoneExtendedCommit(t, app.ChainID(), height, validators, validatorPrivKeys, currentProducerID, winningMilestone)

return &singletonScenario{
app: app,
ctx: ctx,
height: height,
lastSpan: lastSpan,
currentProducerID: currentProducerID,
validators: validators,
extCommitBytes: extCommitBytes,
}
}

// Pre-Ithaca the fallback is gated off, so the singleton-minus-current empty set still
// errors out of PreBlocker. This pins the gate boundary: the fix changes nothing before
// the fork activates.
func TestVeBlopEmptyElectedSetPreIthacaErrors(t *testing.T) {
s := stageSingletonProducer(t)

oldIthaca := helper.GetIthacaHeight()
helper.SetIthacaHeight(0) // disabled: IsIthaca is always false
t.Cleanup(func() { helper.SetIthacaHeight(oldIthaca) })

_, err := s.app.PreBlocker(s.ctx, &abci.RequestFinalizeBlock{
Height: s.height,
Txs: [][]byte{s.extCommitBytes},
ProposerAddress: common.FromHex(s.validators[1].Signer),
})
require.Error(t, err)
require.True(t, strings.Contains(err.Error(), "no candidates found"), err.Error())

// Nothing committed: the failing height replays identically.
milestoneCount, err := s.app.MilestoneKeeper.GetMilestoneCount(s.ctx)
require.NoError(t, err)
require.Equal(t, uint64(1), milestoneCount)
span, err := s.app.BorKeeper.GetLastSpan(s.ctx)
require.NoError(t, err)
require.Equal(t, s.lastSpan.Id, span.Id)
}

// Post-Ithaca the fallback supplies a deterministic non-empty candidate set drawn from
// the milestone supporters, so future-span creation succeeds, the milestone/span commit,
// and the new producer is a supporting validator rather than the excluded incumbent.
func TestVeBlopEmptyElectedSetPostIthacaFallsBack(t *testing.T) {
s := stageSingletonProducer(t)

oldIthaca := helper.GetIthacaHeight()
helper.SetIthacaHeight(s.height)
t.Cleanup(func() { helper.SetIthacaHeight(oldIthaca) })

_, err := s.app.PreBlocker(s.ctx, &abci.RequestFinalizeBlock{
Height: s.height,
Txs: [][]byte{s.extCommitBytes},
ProposerAddress: common.FromHex(s.validators[1].Signer),
})
require.NoError(t, err)

// The milestone committed and a new span was frozen.
milestoneCount, err := s.app.MilestoneKeeper.GetMilestoneCount(s.ctx)
require.NoError(t, err)
require.Equal(t, uint64(2), milestoneCount)

span, err := s.app.BorKeeper.GetLastSpan(s.ctx)
require.NoError(t, err)
require.Equal(t, s.lastSpan.Id+1, span.Id)
require.Len(t, span.SelectedProducers, 1)

// The replacement must be a supporting validator, never the excluded incumbent.
newProducer := span.SelectedProducers[0].ValId
require.NotEqual(t, s.currentProducerID, newProducer)
supporters := map[uint64]struct{}{
s.validators[1].ValId: {},
s.validators[2].ValId: {},
s.validators[3].ValId: {},
}
_, ok := supporters[newProducer]
require.True(t, ok, "new producer %d should be one of the milestone supporters", newProducer)
}

func milestoneExtendedCommit(t *testing.T, chainID string, height int64, validators []*stakeTypes.Validator, validatorPrivKeys []cmtsecp256k1.PrivKey, currentProducerID uint64, milestone *milestoneTypes.MilestoneProposition) []byte {
t.Helper()
require.Len(t, validatorPrivKeys, len(validators))

dummyNonRpExt, err := GetDummyNonRpVoteExtension(height-1, chainID)
require.NoError(t, err)

extCommit := &abci.ExtendedCommitInfo{Round: 0, Votes: make([]abci.ExtendedVoteInfo, 0, len(validators))}
for i, validator := range validators {
var prop *milestoneTypes.MilestoneProposition
if validator.ValId != currentProducerID {
prop = milestone
}

voteExt := sidetxs.VoteExtension{
BlockHash: []byte("repro-previous-block"),
Height: height - 1,
MilestoneProposition: prop,
SideTxResponses: []sidetxs.SideTxResponse{},
}
voteExtBytes, err := voteExt.Marshal()
require.NoError(t, err)

voteInfo := abci.ExtendedVoteInfo{
Validator: abci.Validator{
Address: common.FromHex(validator.Signer),
Power: validator.VotingPower,
},
VoteExtension: voteExtBytes,
NonRpVoteExtension: dummyNonRpExt,
BlockIdFlag: cmtproto.BlockIDFlagCommit,
}
createSignatureForVoteExtension(t, height-1, validatorPrivKeys[i], voteExtBytes, dummyNonRpExt, &voteInfo)
extCommit.Votes = append(extCommit.Votes, voteInfo)
}

bz, err := extCommit.Marshal()
require.NoError(t, err)
return bz
}
2 changes: 1 addition & 1 deletion x/bor/keeper/side_msg_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ func (srv sideMsgServer) PostHandleSetProducerDowntime(ctx sdk.Context, msgI sdk
spanEndBlock = msg.DowntimeRange.StartBlock + params.SpanDuration
}

if err := srv.k.AddNewVeBlopSpan(ctx, validatorId, msg.DowntimeRange.StartBlock, spanEndBlock, lastSpan.BorChainId, latestActiveProducer, uint64(ctx.BlockHeight()), msg.TargetProducerId, nil); err != nil {
if err := srv.k.AddNewVeBlopSpan(ctx, validatorId, msg.DowntimeRange.StartBlock, spanEndBlock, lastSpan.BorChainId, latestActiveProducer, uint64(ctx.BlockHeight()), msg.TargetProducerId, nil, false); err != nil {
logger.Error("Error occurred while adding new veBlop span", "error", err)
return err
}
Expand Down
17 changes: 13 additions & 4 deletions x/bor/keeper/veblop.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@
staketypes "github.com/0xPolygon/heimdall-v2/x/stake/types"
)

// AddNewVeBlopSpan adds a new veBlop (Validator-elected block producer) span
func (k *Keeper) AddNewVeBlopSpan(ctx sdk.Context, currentProducer uint64, startBlock uint64, endBlock uint64, borChainID string, activeValidatorIDs map[uint64]struct{}, heimdallBlock uint64, targetProducerID uint64, excludedProducerIDs map[uint64]struct{}) error {
// AddNewVeBlopSpan adds a new veBlop (Validator-elected block producer) span.
//
// fallbackToActiveSet opts the caller into the post-Ithaca empty-candidate recovery in
// SelectNextSpanProducer. Only the future-span PreBlocker path passes true: there an empty
// candidate set is a fatal chain halt. The rotation / pending-stall / side-message callers pass
// false and keep their existing skip-and-retry behavior on an empty selection.
func (k *Keeper) AddNewVeBlopSpan(ctx sdk.Context, currentProducer uint64, startBlock uint64, endBlock uint64, borChainID string, activeValidatorIDs map[uint64]struct{}, heimdallBlock uint64, targetProducerID uint64, excludedProducerIDs map[uint64]struct{}, fallbackToActiveSet bool) error {

Check warning on line 23 in x/bor/keeper/veblop.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function has 10 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=0xPolygon_heimdall-v2&issues=AZ8XRNuva2RxxbU4bd-U&open=AZ8XRNuva2RxxbU4bd-U&pullRequest=618
logger := k.Logger(ctx)

// select next producers
newProducerId, err := k.SelectNextSpanProducer(ctx, currentProducer, activeValidatorIDs, helper.GetProducerSetLimit(ctx), startBlock, endBlock, targetProducerID, excludedProducerIDs)
newProducerId, err := k.SelectNextSpanProducer(ctx, currentProducer, activeValidatorIDs, helper.GetProducerSetLimit(ctx), startBlock, endBlock, targetProducerID, excludedProducerIDs, fallbackToActiveSet)
if err != nil {
return err
}
Expand Down Expand Up @@ -237,7 +242,7 @@

// SelectNextSpanProducer selects the next producer for a new span.
// It calculates the candidate set, filters by active producers, and selects one.
func (k *Keeper) SelectNextSpanProducer(ctx sdk.Context, currentProducer uint64, activeValidatorIDs map[uint64]struct{}, producerSetLimit, startBlock, endBlock uint64, targetProducerID uint64, excludedProducerIDs map[uint64]struct{}) (uint64, error) {
func (k *Keeper) SelectNextSpanProducer(ctx sdk.Context, currentProducer uint64, activeValidatorIDs map[uint64]struct{}, producerSetLimit, startBlock, endBlock uint64, targetProducerID uint64, excludedProducerIDs map[uint64]struct{}, fallbackToActiveSet bool) (uint64, error) {

Check warning on line 245 in x/bor/keeper/veblop.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function has 9 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=0xPolygon_heimdall-v2&issues=AZ8XRNuva2RxxbU4bd-W&open=AZ8XRNuva2RxxbU4bd-W&pullRequest=618

Check failure on line 245 in x/bor/keeper/veblop.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 36 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=0xPolygon_heimdall-v2&issues=AZ8XRNuva2RxxbU4bd-V&open=AZ8XRNuva2RxxbU4bd-V&pullRequest=618
candidates, err := k.CalculateProducerSet(ctx, producerSetLimit)
if err != nil {
return 0, fmt.Errorf("failed to calculate producer set: %w", err)
Expand Down Expand Up @@ -268,6 +273,10 @@
activeCandidates = filterExcludedProducers(newCandidates, excludedProducerIDs)
}

// Post-Ithaca, recover a candidate so the future-span path (the only opt-in via fallbackToActiveSet) can't hand SelectProducer an empty set (see eligibleProducerFallback).
if len(activeCandidates) == 0 && fallbackToActiveSet && helper.IsIthaca(ctx.BlockHeight()) {
activeCandidates = k.eligibleProducerFallback(ctx, currentProducer, activeValidatorIDs, excludedProducerIDs)
}
Comment thread
pratikspatil024 marked this conversation as resolved.
// If the declaring producer requested a specific replacement, try to honor it.
// The target must still be an active candidate and not down for the range.
// If any check fails, we fall through to round-robin rather than blocking span creation.
Expand Down
55 changes: 55 additions & 0 deletions x/bor/keeper/veblop_producer_fallback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package keeper

import (
"slices"

sdk "github.com/cosmos/cosmos-sdk/types"
)

// eligibleProducerFallback returns a deterministic candidate set for the degenerate case
// where producer election leaves no selectable candidate other than the current producer
// (the elected set has collapsed to the lone incumbent, or every alternative was filtered
// out). It draws from the caller's active set (the milestone supporters) first, then the
// full validator set, each sorted ascending with the current and excluded producers removed.
//
// Only the future-span PreBlocker path reaches this, where an empty candidate set is a fatal
// chain halt. There it is always non-empty: a >2/3 milestone that excludes the current
// producer leaves a non-empty supporting set, so SelectProducer never receives an empty slice
// and the halt cannot occur. The validator-set step is defense-in-depth for that guarantee;
// the non-fatal rotation paths do not opt into this fallback and keep their skip-and-retry
// behavior.
func (k *Keeper) eligibleProducerFallback(ctx sdk.Context, currentProducer uint64, activeValidatorIDs, excludedProducerIDs map[uint64]struct{}) []uint64 {
if c := sortedEligibleProducers(activeValidatorIDs, currentProducer, excludedProducerIDs); len(c) > 0 {
return c
}

valSet, err := k.sk.GetValidatorSet(ctx)
if err != nil {
k.Logger(ctx).Error("Failed to get validator set for producer fallback", "error", err)
return nil
}

valIDs := make(map[uint64]struct{}, len(valSet.Validators))
for _, v := range valSet.Validators {
valIDs[v.ValId] = struct{}{}
}
return sortedEligibleProducers(valIDs, currentProducer, excludedProducerIDs)
}
Comment thread
pratikspatil024 marked this conversation as resolved.

// sortedEligibleProducers returns the ids in set, ascending, omitting the current producer
// and any excluded producer. Sorting makes the result deterministic across validators (Go
// map iteration order is not).
func sortedEligibleProducers(set map[uint64]struct{}, currentProducer uint64, excluded map[uint64]struct{}) []uint64 {
ids := make([]uint64, 0, len(set))
for id := range set {
if id == currentProducer {
continue
}
if _, isExcluded := excluded[id]; isExcluded {
continue
}
ids = append(ids, id)
}
slices.Sort(ids)
return ids
}
Loading
Loading