diff --git a/consensus/consensus.go b/consensus/consensus.go index be924ffeca..97877ed3ce 100644 --- a/consensus/consensus.go +++ b/consensus/consensus.go @@ -166,4 +166,8 @@ type PoSA interface { VerifyVote(chain ChainHeaderReader, vote *types.VoteEnvelope) error IsActiveValidatorAt(chain ChainHeaderReader, header *types.Header, checkVoteKeyFn func(bLSPublicKey *types.BLSPublicKey) bool) bool NextProposalBlock(chain ChainHeaderReader, header *types.Header, proposer common.Address) (uint64, uint64, error) + // IsVotingBlock returns whether validators vote on the given block (BEP-667). + IsVotingBlock(header *types.Header) bool + // GetVoteInterval returns N, the number of blocks between consecutive voting blocks (BEP-667). + GetVoteInterval(header *types.Header) uint64 } diff --git a/consensus/parlia/finality_test.go b/consensus/parlia/finality_test.go index 495f6d4802..e6cdce4a93 100644 --- a/consensus/parlia/finality_test.go +++ b/consensus/parlia/finality_test.go @@ -24,6 +24,7 @@ import ( "github.com/ethereum/go-ethereum/consensus" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/rlp" ) type finalizedHeaderChain struct { @@ -173,3 +174,245 @@ func TestGetFinalizedHeaderUsesParentSnapshotForFastFinalityQuorum(t *testing.T) } var _ consensus.ChainHeaderReader = (*finalizedHeaderChain)(nil) + +func TestGetVoteInterval(t *testing.T) { + pasteurTime := uint64(1000) + cfg := ¶ms.ChainConfig{ + ChainID: big.NewInt(56), + LondonBlock: big.NewInt(0), + PasteurTime: &pasteurTime, + Parlia: ¶ms.ParliaConfig{}, + } + genesis := &types.Header{Number: big.NewInt(0)} + engine := New(cfg, nil, nil, genesis.Hash()) + + prePasteur := &types.Header{Number: big.NewInt(10), Time: 999} + if got := engine.GetVoteInterval(prePasteur); got != 1 { + t.Fatalf("expected vote interval 1 pre-Pasteur, got %d", got) + } + if !engine.IsVotingBlock(prePasteur) { + t.Fatal("pre-Pasteur: every block should be a voting block") + } + + postPasteur := &types.Header{Number: big.NewInt(10), Time: 1000} + if got := engine.GetVoteInterval(postPasteur); got != pasteurVoteInterval { + t.Fatalf("expected vote interval %d post-Pasteur, got %d", pasteurVoteInterval, got) + } +} + +func TestIsVotingBlock(t *testing.T) { + pasteurTime := uint64(0) + cfg := ¶ms.ChainConfig{ + ChainID: big.NewInt(56), + LondonBlock: big.NewInt(0), + PasteurTime: &pasteurTime, + Parlia: ¶ms.ParliaConfig{}, + } + genesis := &types.Header{Number: big.NewInt(0)} + engine := New(cfg, nil, nil, genesis.Hash()) + + tests := []struct { + blockNum uint64 + isVoting bool + }{ + {0, true}, + {1, false}, + {2, true}, + {3, false}, + {4, true}, + {100, true}, + {101, false}, + } + for _, tc := range tests { + header := &types.Header{Number: big.NewInt(int64(tc.blockNum)), Time: 1} + got := engine.IsVotingBlock(header) + if got != tc.isVoting { + t.Errorf("IsVotingBlock(%d) = %v, want %v", tc.blockNum, got, tc.isVoting) + } + } +} + +func TestGetAncestorGenerationDepthPasteur(t *testing.T) { + pasteurTime := uint64(0) + fermiTime := uint64(0) + cfg := ¶ms.ChainConfig{ + ChainID: big.NewInt(56), + LondonBlock: big.NewInt(0), + FermiTime: &fermiTime, + PasteurTime: &pasteurTime, + Parlia: ¶ms.ParliaConfig{}, + } + genesis := &types.Header{Number: big.NewInt(0)} + engine := New(cfg, nil, nil, genesis.Hash()) + + header := &types.Header{Number: big.NewInt(10), Time: 1} + depth := engine.GetAncestorGenerationDepth(header) + if depth != pasteurVoteAncestorDepth { + t.Fatalf("expected ancestor depth %d for Pasteur, got %d", pasteurVoteAncestorDepth, depth) + } +} + +func TestUpdateAttestationVoteInterval(t *testing.T) { + pasteurTime := uint64(0) + fermiTime := uint64(0) + cfg := ¶ms.ChainConfig{ + ChainID: big.NewInt(56), + LondonBlock: big.NewInt(0), + LubanBlock: big.NewInt(0), + FermiTime: &fermiTime, + PasteurTime: &pasteurTime, + Parlia: ¶ms.ParliaConfig{}, + } + + makeHeaderWithAttestation := func(number uint64, attestation *types.VoteAttestation) *types.Header { + attBytes, err := rlp.EncodeToBytes(attestation) + if err != nil { + t.Fatalf("failed to encode attestation: %v", err) + } + extra := make([]byte, extraVanity+len(attBytes)+extraSeal) + copy(extra[extraVanity:], attBytes) + return &types.Header{ + Number: big.NewInt(int64(number)), + Time: 1, + Extra: extra, + } + } + + // Case 1: consecutive voting blocks (source=2, target=4, gap=N=2) → full attestation replacement + snap := &Snapshot{ + config: cfg.Parlia, + EpochLength: 200, + Attestation: &types.VoteData{ + SourceNumber: 0, + SourceHash: common.Hash{0x01}, + TargetNumber: 2, + TargetHash: common.Hash{0x02}, + }, + } + header4 := makeHeaderWithAttestation(4, &types.VoteAttestation{ + Data: &types.VoteData{ + SourceNumber: 2, + SourceHash: common.Hash{0x02}, + TargetNumber: 4, + TargetHash: common.Hash{0x04}, + }, + }) + snap.updateAttestation(header4, cfg) + if snap.Attestation.SourceNumber != 2 || snap.Attestation.TargetNumber != 4 { + t.Fatalf("consecutive: expected attestation (source=2, target=4), got (source=%d, target=%d)", + snap.Attestation.SourceNumber, snap.Attestation.TargetNumber) + } + + // Case 2: non-consecutive voting blocks (source=2, target=6, gap=4≠N=2) → only target advances + snap2 := &Snapshot{ + config: cfg.Parlia, + EpochLength: 200, + Attestation: &types.VoteData{ + SourceNumber: 0, + SourceHash: common.Hash{0x01}, + TargetNumber: 2, + TargetHash: common.Hash{0x02}, + }, + } + header6 := makeHeaderWithAttestation(6, &types.VoteAttestation{ + Data: &types.VoteData{ + SourceNumber: 2, + SourceHash: common.Hash{0x02}, + TargetNumber: 6, + TargetHash: common.Hash{0x06}, + }, + }) + snap2.updateAttestation(header6, cfg) + if snap2.Attestation.SourceNumber != 0 { + t.Fatalf("non-consecutive: expected source=0 (not advanced), got %d", snap2.Attestation.SourceNumber) + } + if snap2.Attestation.TargetNumber != 6 { + t.Fatalf("non-consecutive: expected target=6, got %d", snap2.Attestation.TargetNumber) + } +} + +func TestGetFinalizedHeaderWithVoteInterval(t *testing.T) { + pasteurTime := uint64(0) + cfg := ¶ms.ChainConfig{ + ChainID: big.NewInt(56), + LondonBlock: big.NewInt(0), + PlatoBlock: big.NewInt(0), + PasteurTime: &pasteurTime, + Parlia: ¶ms.ParliaConfig{}, + } + + genesis := &types.Header{Number: big.NewInt(0)} + block1 := &types.Header{Number: big.NewInt(1), Time: 1, ParentHash: genesis.Hash()} + + block2 := &types.Header{Number: big.NewInt(2), Time: 1, ParentHash: block1.Hash()} + block3 := &types.Header{Number: big.NewInt(3), Time: 1, ParentHash: block2.Hash()} + block4 := &types.Header{Number: big.NewInt(4), Time: 1, ParentHash: block3.Hash()} + + chain := &finalizedHeaderChain{ + cfg: cfg, + current: block4, + byHash: map[common.Hash]*types.Header{ + genesis.Hash(): genesis, + block1.Hash(): block1, + block2.Hash(): block2, + block3.Hash(): block3, + block4.Hash(): block4, + }, + byNumber: map[uint64]*types.Header{ + 0: genesis, + 1: block1, + 2: block2, + 3: block3, + 4: block4, + }, + } + + // Snapshot at block 4: justified=2, finalized=0 + snap4 := &Snapshot{ + config: cfg.Parlia, + Number: 4, + Hash: block4.Hash(), + Validators: makeValidatorSet(3), + Attestation: &types.VoteData{ + SourceNumber: 0, SourceHash: genesis.Hash(), + TargetNumber: 2, TargetHash: block2.Hash(), + }, + Recents: make(map[uint64]common.Address), + RecentForkHashes: make(map[uint64]string), + } + snap3 := &Snapshot{ + config: cfg.Parlia, + Number: 3, + Hash: block3.Hash(), + Validators: makeValidatorSet(3), + Attestation: &types.VoteData{ + SourceNumber: 0, SourceHash: genesis.Hash(), + TargetNumber: 2, TargetHash: block2.Hash(), + }, + Recents: make(map[uint64]common.Address), + RecentForkHashes: make(map[uint64]string), + } + + engine := New(cfg, nil, nil, genesis.Hash()) + engine.recentSnaps.Add(block4.Hash(), snap4) + engine.recentSnaps.Add(block3.Hash(), snap3) + engine.VotePool = &fixedVotePool{n: 2} + + // block4 is a voting block and justified+N==4: early finality should promote block2 + finalizedHeader := engine.GetFinalizedHeader(chain, block4) + if finalizedHeader == nil { + t.Fatal("expected finalized header, got nil") + } + if finalizedHeader.Number.Uint64() != 2 { + t.Fatalf("expected finalized block 2, got %d", finalizedHeader.Number.Uint64()) + } + + // block3 is not a voting block: should fall back to snapshot source (block 0) + finalizedFromBlock3 := engine.GetFinalizedHeader(chain, block3) + if finalizedFromBlock3 == nil { + t.Fatal("expected finalized header from block3, got nil") + } + if finalizedFromBlock3.Number.Uint64() != 0 { + t.Fatalf("expected finalized block 0 from non-voting block, got %d", finalizedFromBlock3.Number.Uint64()) + } +} diff --git a/consensus/parlia/parlia.go b/consensus/parlia/parlia.go index 6aa8c690c1..e7f4e6d5e9 100644 --- a/consensus/parlia/parlia.go +++ b/consensus/parlia/parlia.go @@ -87,6 +87,10 @@ const ( finalityRewardInterval = 200 kAncestorGenerationDepth = 3 + + // BEP-667: vote interval N and ancestor search depth (in voting-block units). + pasteurVoteInterval uint64 = 2 + pasteurVoteAncestorDepth uint64 = 2 ) var ( @@ -477,6 +481,11 @@ func (p *Parlia) verifyVoteAttestation(chain consensus.ChainHeaderReader, header if attestation == nil { return nil } + + if !p.IsVotingBlock(header) { + return errors.New("non-voting block must not carry vote attestation") + } + if attestation.Data == nil { return errors.New("invalid attestation, vote data is nil") } @@ -514,12 +523,16 @@ func (p *Parlia) verifyVoteAttestation(chain consensus.ChainHeaderReader, header match := false ancestor := parent ancestorParents := trimParents(parents) - for range p.GetAncestorGenerationDepth(header) { + for votingLeft := p.GetAncestorGenerationDepth(header); votingLeft > 0; { if targetNumber == ancestor.Number.Uint64() && targetHash == ancestor.Hash() { match = true break } + if p.IsVotingBlock(ancestor) { + votingLeft-- + } + ancestor, err = p.getParent(chain, ancestor, ancestorParents) if err != nil { return err @@ -530,6 +543,11 @@ func (p *Parlia) verifyVoteAttestation(chain consensus.ChainHeaderReader, header return fmt.Errorf("invalid attestation, target mismatch, real block: %d, hash: %s", targetNumber, targetHash) } + // Use ancestor's own header time so pre-Pasteur blocks are accepted correctly at fork boundaries. + if !p.IsVotingBlock(ancestor) { + return errors.New("invalid attestation, target is not a voting block") + } + // === Step 4: Check quorum === // The snapshot should be the targetNumber-1 block's snapshot. snap, err := p.snapshot(chain, ancestor.Number.Uint64()-1, ancestor.ParentHash, ancestorParents) @@ -1054,6 +1072,10 @@ func (p *Parlia) assembleVoteAttestation(chain consensus.ChainHeaderReader, head return nil } + if !p.IsVotingBlock(header) { + return nil + } + // === Step 2: Find target header with quorum votes === parent := chain.GetHeaderByHash(header.ParentHash) if parent == nil { @@ -1068,16 +1090,19 @@ func (p *Parlia) assembleVoteAttestation(chain consensus.ChainHeaderReader, head targetHeader = parent targetHeaderParentSnap *Snapshot ) - for range p.GetAncestorGenerationDepth(header) { - snap, err := p.snapshot(chain, targetHeader.Number.Uint64()-1, targetHeader.ParentHash, nil) - if err != nil { - return err - } - votes = p.VotePool.FetchVotesByBlockHash(targetHeader.Hash(), justifiedBlockNumber) - quorum := cmath.CeilDiv(len(snap.Validators)*2, 3) - if len(votes) >= quorum { - targetHeaderParentSnap = snap - break + for votingLeft := p.GetAncestorGenerationDepth(header); votingLeft > 0; { + if p.IsVotingBlock(targetHeader) { + snap, err := p.snapshot(chain, targetHeader.Number.Uint64()-1, targetHeader.ParentHash, nil) + if err != nil { + return err + } + votes = p.VotePool.FetchVotesByBlockHash(targetHeader.Hash(), justifiedBlockNumber) + quorum := cmath.CeilDiv(len(snap.Validators)*2, 3) + if len(votes) >= quorum { + targetHeaderParentSnap = snap + break + } + votingLeft-- } targetHeader = chain.GetHeaderByHash(targetHeader.ParentHash) @@ -1627,6 +1652,10 @@ func (p *Parlia) VerifyVote(chain consensus.ChainHeaderReader, vote *types.VoteE return errors.New("target number mismatch") } + if !p.IsVotingBlock(header) { + return errors.New("vote target is not a voting block") + } + justifiedBlockNumber, justifiedBlockHash, err := p.GetJustifiedNumberAndHash(chain, []*types.Header{header}) if err != nil { log.Error("failed to get the highest justified number and hash", "headerNumber", header.Number, "headerHash", header.Hash()) @@ -2302,11 +2331,11 @@ func (p *Parlia) GetFinalizedHeader(chain consensus.ChainHeaderReader, header *t currentJustifiedHash := snap.Attestation.TargetHash currentJustifiedNumber := snap.Attestation.TargetNumber // Try to check if currentJustifiedNumber can become finalized by checking VotePool. - // We only need to check currentJustifiedNumber + 1, since currentJustifiedNumber is already the latest justified. - if p.VotePool != nil && currentJustifiedNumber == header.Number.Uint64()-1 { + voteInterval := p.GetVoteInterval(header) + if p.VotePool != nil && p.IsVotingBlock(header) && + currentJustifiedNumber+voteInterval == header.Number.Uint64() { parentSnap, err := p.snapshot(chain, header.Number.Uint64()-1, header.ParentHash, nil) if err == nil { - // Check if the next block (direct child) has reached quorum in VotePool votes := p.VotePool.FetchVotesByBlockHash(header.Hash(), currentJustifiedNumber) quorum := cmath.CeilDiv(len(parentSnap.Validators)*2, 3) @@ -2497,12 +2526,28 @@ func (p *Parlia) detectNewVersionWithFork(chain consensus.ChainHeaderReader, hea } } -// TODO(Nathan): use kAncestorGenerationDepth directly instead of this func once Fermi hardfork passed +// GetVoteInterval returns N, the vote interval (BEP-667). Pre-Pasteur: 1, Pasteur+: pasteurVoteInterval. +func (p *Parlia) GetVoteInterval(header *types.Header) uint64 { + if p.chainConfig.IsPasteur(header.Number, header.Time) { + return pasteurVoteInterval + } + return 1 +} + +// IsVotingBlock returns whether the given block is a voting block (blockNumber % N == 0). +func (p *Parlia) IsVotingBlock(header *types.Header) bool { + return header.Number.Uint64()%p.GetVoteInterval(header) == 0 +} + +// GetAncestorGenerationDepth returns how many voting-block ancestors to search for quorum votes. +// Pre-Fermi: 1, Fermi: 3, Pasteur: pasteurVoteAncestorDepth. func (p *Parlia) GetAncestorGenerationDepth(header *types.Header) uint64 { + if p.chainConfig.IsPasteur(header.Number, header.Time) { + return pasteurVoteAncestorDepth + } if p.chainConfig.IsFermi(header.Number, header.Time) { return kAncestorGenerationDepth } - return 1 } diff --git a/consensus/parlia/snapshot.go b/consensus/parlia/snapshot.go index 047ddf4754..e62e97879e 100644 --- a/consensus/parlia/snapshot.go +++ b/consensus/parlia/snapshot.go @@ -228,7 +228,12 @@ func (s *Snapshot) updateAttestation(header *types.Header, chainConfig *params.C // Two scenarios for s.Attestation being nil: // 1) The first attestation is assembled. // 2) The snapshot on disk is missing, prompting the creation of a new snapshot using `newSnapshot`. - if s.Attestation != nil && attestation.Data.SourceNumber+1 != attestation.Data.TargetNumber { + // Post-Pasteur, consecutive voting blocks are N apart, so the adjacency check uses voteInterval. + voteInterval := uint64(1) + if chainConfig.IsPasteur(header.Number, header.Time) { + voteInterval = pasteurVoteInterval + } + if s.Attestation != nil && attestation.Data.SourceNumber+voteInterval != attestation.Data.TargetNumber { s.Attestation.TargetNumber = attestation.Data.TargetNumber s.Attestation.TargetHash = attestation.Data.TargetHash } else { diff --git a/core/vote/vote_manager.go b/core/vote/vote_manager.go index ad83b4e51f..29040844d5 100644 --- a/core/vote/vote_manager.go +++ b/core/vote/vote_manager.go @@ -162,6 +162,11 @@ func (voteManager *VoteManager) loop() { continue } + if !voteManager.engine.IsVotingBlock(curHead) { + log.Debug("skip voting for non-voting block", "blockNumber", curHead.Number.Uint64()) + continue + } + // Vote for curBlockHeader block. vote := &types.VoteData{ TargetNumber: curHead.Number.Uint64(), @@ -180,12 +185,13 @@ func (voteManager *VoteManager) loop() { } if p, ok := voteManager.engine.(*parlia.Parlia); ok { - // Approximately equal to the block interval of next block, except for the switch block. blockInterval, err := p.BlockInterval(voteManager.chain, curHead) if err != nil { log.Debug("failed to get BlockInterval when voting") } - voteAssembledTime := time.UnixMilli(int64((curHead.MilliTimestamp() + p.GetAncestorGenerationDepth(curHead)*blockInterval))) + // BEP-667: votes for curHead will be included in the next voting block, + // which is voteInterval blocks away, not GetAncestorGenerationDepth blocks away. + voteAssembledTime := time.UnixMilli(int64(curHead.MilliTimestamp() + p.GetVoteInterval(curHead)*blockInterval)) timeForBroadcast := 50 * time.Millisecond // enough to broadcast a vote in the same region if time.Now().Add(timeForBroadcast).After(voteAssembledTime) { log.Warn("too late to vote", "Head.Time(Millisecond)", curHead.MilliTimestamp(), "Now(Millisecond)", time.Now().UnixMilli()) @@ -214,28 +220,35 @@ func (voteManager *VoteManager) loop() { } // check the latest justified block, which indicating the stability of the network - curJustifiedNumber, _, err := voteManager.engine.GetJustifiedNumberAndHash(voteManager.chain, []*types.Header{curHead}) + voteInterval := voteManager.engine.GetVoteInterval(curHead) + curJustifiedNumber, curJustifiedHash, err := voteManager.engine.GetJustifiedNumberAndHash(voteManager.chain, []*types.Header{curHead}) if err == nil && curJustifiedNumber != 0 { - if curJustifiedNumber+1 != curHead.Number.Uint64() { - log.Debug("not justified", "blockNumber", curHead.Number.Uint64()-1) + if curJustifiedNumber+voteInterval != curHead.Number.Uint64() { + log.Debug("not justified", "blockNumber", curHead.Number.Uint64()-voteInterval) notJustified.Inc(1) } else { - parent := voteManager.chain.GetHeaderByHash(curHead.ParentHash) - if parent != nil { - if parent.Difficulty.Cmp(diffInTurn) == 0 { + // BEP-667: the justified block is curJustifiedNumber (= curHead - voteInterval), + // not curHead's direct parent. Use the justified block's header for the + // inTurn check so the metric remains meaningful after the Pasteur fork. + justifiedHeader := voteManager.chain.GetHeaderByHash(curJustifiedHash) + if justifiedHeader != nil { + if justifiedHeader.Difficulty.Cmp(diffInTurn) == 0 { inTurnJustified.Inc(1) } else { - log.Debug("not in turn block justified", "blockNumber", parent.Number.Int64(), "blockHash", parent.Hash()) + log.Debug("not in turn block justified", "blockNumber", justifiedHeader.Number.Int64(), "blockHash", justifiedHeader.Hash()) notInTurnJustified.Inc(1) } - lastJustifiedNumber, _, err := voteManager.engine.GetJustifiedNumberAndHash(voteManager.chain, []*types.Header{parent}) - if err == nil { - if lastJustifiedNumber == 0 || lastJustifiedNumber+1 == curJustifiedNumber { - continuousJustified.Inc(1) - } else { - log.Debug("not continuous block justified", "lastJustified", lastJustifiedNumber, "curJustified", curJustifiedNumber) - notContinuousJustified.Inc(1) + parent := voteManager.chain.GetHeaderByHash(curHead.ParentHash) + if parent != nil { + lastJustifiedNumber, _, err := voteManager.engine.GetJustifiedNumberAndHash(voteManager.chain, []*types.Header{parent}) + if err == nil { + if lastJustifiedNumber == 0 || lastJustifiedNumber+voteInterval == curJustifiedNumber { + continuousJustified.Inc(1) + } else { + log.Debug("not continuous block justified", "lastJustified", lastJustifiedNumber, "curJustified", curJustifiedNumber) + notContinuousJustified.Inc(1) + } } } } diff --git a/core/vote/vote_pool_test.go b/core/vote/vote_pool_test.go index 7bb0b1614e..6f88b04a9a 100644 --- a/core/vote/vote_pool_test.go +++ b/core/vote/vote_pool_test.go @@ -98,6 +98,11 @@ func (mip *mockInvalidPOSA) VerifyVote(chain consensus.ChainHeaderReader, vote * return nil } +func (mp *mockPOSA) IsVotingBlock(header *types.Header) bool { return true } +func (mp *mockPOSA) GetVoteInterval(header *types.Header) uint64 { return 1 } +func (mip *mockInvalidPOSA) IsVotingBlock(header *types.Header) bool { return true } +func (mip *mockInvalidPOSA) GetVoteInterval(header *types.Header) uint64 { return 1 } + func (mp *mockPOSA) IsActiveValidatorAt(chain consensus.ChainHeaderReader, header *types.Header, checkVoteKeyFn func(bLSPublicKey *types.BLSPublicKey) bool) bool { return true }