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
8 changes: 6 additions & 2 deletions pkg/miner/endorsementpool/endorsement_pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,10 +196,14 @@ func (p *EndorsementPool) GetAll() []proto.EndorseBlock {
return out
}

func (p *EndorsementPool) FormFinalization(lastFinalizedHeight proto.Height) (proto.FinalizationVoting, error) {
func (p *EndorsementPool) FormFinalization() (proto.FinalizationVoting, error) {
p.mu.Lock()
defer p.mu.Unlock()

if len(p.h) == 0 {
return proto.FinalizationVoting{}, errors.New("failed to form finalization: pool is empty")
}

signatures := make([]bls.Signature, 0, len(p.h))
endorsersIndexes := make([]int32, 0, len(p.h))
var aggregatedSignature bls.Signature
Expand All @@ -222,7 +226,7 @@ func (p *EndorsementPool) FormFinalization(lastFinalizedHeight proto.Height) (pr

return proto.FinalizationVoting{
AggregatedEndorsementSignature: aggregatedSignature,
FinalizedBlockHeight: lastFinalizedHeight,
FinalizedBlockHeight: proto.Height(p.h[0].eb.FinalizedBlockHeight),
EndorserIndexes: endorsersIndexes,
ConflictEndorsements: p.conflicts,
}, nil
Expand Down
16 changes: 5 additions & 11 deletions pkg/node/fsm/ng_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,18 +431,16 @@ func (a *NGState) BlockEndorsement(blockEndorsement *proto.EndorseBlock) (State,
return newNGState(a.baseInfo), nil, nil
}

func (a *NGState) getCurrentFinalizationVoting(height proto.Height,
lastFinalizedHeight proto.Height) (*proto.FinalizationVoting, error) {
blockFinalization, err := a.tryGetCurrentFinalizationVoting(height, lastFinalizedHeight)
func (a *NGState) getCurrentFinalizationVoting(height proto.Height) (*proto.FinalizationVoting, error) {
blockFinalization, err := a.tryGetCurrentFinalizationVoting(height)
if err != nil {
slog.Debug("did not form finalization voting", "err", err)
return nil, err
}
return blockFinalization, nil
}

func (a *NGState) tryGetCurrentFinalizationVoting(height proto.Height,
lastFinalizedHeight proto.Height) (*proto.FinalizationVoting, error) {
func (a *NGState) tryGetCurrentFinalizationVoting(height proto.Height) (*proto.FinalizationVoting, error) {
// No finalization since nobody endorsed the last block.
if a.baseInfo.endorsements.Len() == 0 {
return nil, errNoEndorsements
Expand Down Expand Up @@ -474,7 +472,7 @@ func (a *NGState) tryGetCurrentFinalizationVoting(height proto.Height,
slog.Debug("No committed generators found for finalization calculation")
}

finalization, finErr := a.baseInfo.endorsements.FormFinalization(lastFinalizedHeight)
finalization, finErr := a.baseInfo.endorsements.FormFinalization()
if finErr != nil {
return nil, finErr
}
Expand Down Expand Up @@ -713,11 +711,7 @@ func (a *NGState) mineMicro(
}
var blockFinalization *proto.FinalizationVoting
if finalityActivated {
lastFinalizedHeight, lastHeightErr := a.baseInfo.storage.LastFinalizedHeight()
if lastHeightErr != nil {
return a, nil, a.Errorf(lastHeightErr)
}
blockFinalization, err = a.getCurrentFinalizationVoting(height, lastFinalizedHeight)
blockFinalization, err = a.getCurrentFinalizationVoting(height)
if err != nil && !errors.Is(err, errNoFinalization) && !errors.Is(err, errNoEndorsements) {
return a, nil, a.Errorf(err)
}
Expand Down
19 changes: 16 additions & 3 deletions pkg/state/appender.go
Original file line number Diff line number Diff line change
Expand Up @@ -1450,7 +1450,7 @@ func (f *finalizationProcessor) loadLastFinalizedHeight(
) (proto.Height, error) {
calculatedFinalizedHeight := proto.CalculateLastFinalizedHeight(height)

storedFinalizedHeight, err := f.stor.finalizations.newest()
storedFinalizedHeight, err := f.stor.finalizations.newestForProcessing()
if err != nil && !errors.Is(err, ErrNoFinalization) && !errors.Is(err, ErrNoFinalizationHistory) {
return 0, err
}
Expand Down Expand Up @@ -1709,8 +1709,21 @@ func (f *finalizationProcessor) prepareFinalizationVerification(
sb.WriteString(",")
}
slog.Debug("failed to verify finalization signature",
"signature", finalizationVoting.AggregatedEndorsementSignature.String(),
"height", height,
"periodStart", periodStart,
"currentBlockID", currentBlock.BlockID().String(),
"currentParentBlockID", currentBlock.Parent.String(),
"lastFinalizedHeight", lastFinalizedHeight,
"lastFinalizedBlockID", lastFinalizedBlockID.String(),
"endorsedBlockID", endorsedBlockID.String(),
"votingFinalizedHeight", finalizationVoting.FinalizedBlockHeight,
"FinalizationEndorserIndexes", finalizationVoting.EndorserIndexes,
"FinalizationConflictEndorsementsCount", len(finalizationVoting.ConflictEndorsements),
"FinalizationConflictEndorsements", finalizationVoting.ConflictEndorsements,
"FinalizationSignature", finalizationVoting.AggregatedEndorsementSignature.String(),
"msg", msg,
"msgLen", len(msg),
"endorsersCount", len(endorsersPK),
"endorsersPKs", sb.String(),
"err", verifyErr,
)
Expand All @@ -1732,7 +1745,7 @@ func (f *finalizationProcessor) finalizeParent(height proto.Height, endorsedBloc
"not equal to parent's blockID while trying to finalize,"+
"endorsedBlockID: %s, parentBlockID %s", endorsedBlockID.String(), parentID.String())
}
if storErr := f.stor.finalizations.store(finalizedHeight, currentBlockID); storErr != nil {
if storErr := f.stor.finalizations.store(finalizedHeight, height, currentBlockID); storErr != nil {
return storErr
}
slog.Debug("finalized block and saved finalization in state:",
Expand Down
108 changes: 82 additions & 26 deletions pkg/state/finalization.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import (
var ErrNoFinalization = errors.New("no finalized block recorded")
var ErrNoFinalizationHistory = errors.New("no finalization in history")

// finalizationRecord stores only last finalized height.
// finalizationRecord stores finalized height and pending (pre-finalized) height.
type finalizationRecord struct {
FinalizedBlockHeight proto.Height `cbor:"0,keyasint,omitempty"`
PendingBlockHeight proto.Height `cbor:"1,keyasint,omitempty"`
}

func (fr *finalizationRecord) marshalBinary() ([]byte, error) {
Expand All @@ -32,48 +33,103 @@ func newFinalizations(hs *historyStorage) *finalizations {
return &finalizations{hs: hs}
}

// store replaces existing finalization with a new height.
func (f *finalizations) store(
finalizedBlockHeight proto.Height,
currentBlockID proto.BlockID,
) error {
key := finalizationKey{}

rec := finalizationRecord{
FinalizedBlockHeight: finalizedBlockHeight,
func (f *finalizations) newestRecord() (*finalizationRecord, error) {
data, err := f.hs.newestTopEntryData([]byte{finalizationKeyPrefix})
if err != nil {
if isNotFoundInHistoryOrDBErr(err) {
return nil, ErrNoFinalizationHistory
}
return nil, fmt.Errorf("failed to retrieve finalization record: %w", err)
}
var rec finalizationRecord
if unmarshalErr := rec.unmarshalBinary(data); unmarshalErr != nil {
return nil, fmt.Errorf("failed to unmarshal finalization record: %w", unmarshalErr)
}
return &rec, nil
}

func (f *finalizations) writeRecord(rec *finalizationRecord, currentBlockID proto.BlockID) error {
newData, err := rec.marshalBinary()
if err != nil {
return fmt.Errorf("failed to marshal finalization record: %w", err)
}

if addErr := f.hs.addNewEntry(finalization, key.bytes(), newData, currentBlockID); addErr != nil {
if addErr := f.hs.addNewEntry(finalization, []byte{finalizationKeyPrefix}, newData, currentBlockID); addErr != nil {
return fmt.Errorf("failed to add finalization record: %w", addErr)
}

return nil
}

// newest returns the last finalized height.
func (f *finalizations) newest() (proto.Height, error) {
key := finalizationKey{}
data, err := f.hs.newestTopEntryData(key.bytes())
func (f *finalizations) newestHeightForProcessing(rec *finalizationRecord) (proto.Height, error) {
if rec.FinalizedBlockHeight == 0 && rec.PendingBlockHeight == 0 {
return 0, ErrNoFinalization
}
if rec.PendingBlockHeight > rec.FinalizedBlockHeight {
return rec.PendingBlockHeight, nil
}
return rec.FinalizedBlockHeight, nil
}

func (f *finalizations) newestVisibleHeight(rec *finalizationRecord, currentHeight proto.Height) (proto.Height, error) {
if rec.FinalizedBlockHeight == 0 && rec.PendingBlockHeight == 0 {
return 0, ErrNoFinalization
}
if rec.PendingBlockHeight != 0 && currentHeight >= rec.PendingBlockHeight+2 {
if rec.PendingBlockHeight > rec.FinalizedBlockHeight {
return rec.PendingBlockHeight, nil
}
}
if rec.FinalizedBlockHeight == 0 {
return 0, ErrNoFinalization
}
return rec.FinalizedBlockHeight, nil
}

// store writes pending finalization for the current block and promotes matured pending value.
func (f *finalizations) store(
finalizedBlockHeight proto.Height,
currentHeight proto.Height,
currentBlockID proto.BlockID,
) error {
rec, err := f.newestRecord()
if err != nil {
if isNotFoundInHistoryOrDBErr(err) {
return 0, ErrNoFinalizationHistory
if errors.Is(err, ErrNoFinalization) || errors.Is(err, ErrNoFinalizationHistory) {
rec = &finalizationRecord{}
} else {
return err
}
}
if rec.PendingBlockHeight != 0 && currentHeight >= rec.PendingBlockHeight+2 {
if rec.PendingBlockHeight > rec.FinalizedBlockHeight {
rec.FinalizedBlockHeight = rec.PendingBlockHeight
}
return 0, fmt.Errorf("failed to retrieve finalization record: %w", err)
}
if currentHeight >= finalizedBlockHeight+2 && finalizedBlockHeight > rec.FinalizedBlockHeight {
rec.FinalizedBlockHeight = finalizedBlockHeight
}
rec.PendingBlockHeight = finalizedBlockHeight
return f.writeRecord(rec, currentBlockID)
}

var rec finalizationRecord
if unmarshalErr := rec.unmarshalBinary(data); unmarshalErr != nil {
return 0, fmt.Errorf("failed to unmarshal finalization record: %w", unmarshalErr)
// newestForProcessing returns latest known finalization immediately, including pre-finalized value.
func (f *finalizations) newestForProcessing() (proto.Height, error) {
rec, err := f.newestRecord()
if err != nil {
return 0, err
}
return f.newestHeightForProcessing(rec)
}

if rec.FinalizedBlockHeight == 0 {
return 0, ErrNoFinalization
// newestVisible returns delayed finalization height which is exposed outside finalization processing.
func (f *finalizations) newestVisible(currentHeight proto.Height) (proto.Height, error) {
rec, err := f.newestRecord()
if err != nil {
return 0, err
}
return f.newestVisibleHeight(rec, currentHeight)
}

return rec.FinalizedBlockHeight, nil
// newest keeps backward-compatible semantics for internal callers:
// return latest known finalization immediately.
func (f *finalizations) newest() (proto.Height, error) {
return f.newestForProcessing()
}
6 changes: 0 additions & 6 deletions pkg/state/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -780,9 +780,3 @@ func (k *commitmentKey) bytes() []byte {
binary.BigEndian.PutUint32(buf[1:], k.periodStart)
return buf
}

type finalizationKey struct{}

func (k finalizationKey) bytes() []byte {
return []byte{finalizationKeyPrefix}
}
4 changes: 2 additions & 2 deletions pkg/state/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -2375,7 +2375,7 @@ func (s *stateManager) softRollback(blockID proto.BlockID) error {
return rollbackErr
}
if finalizationExists {
if storeErr := s.stor.finalizations.store(finalizationHeight, blockID); storeErr != nil {
if storeErr := s.stor.finalizations.store(finalizationHeight, height, blockID); storeErr != nil {
return wrapErr(stateerr.RollbackError, storeErr)
}
if flushErr := s.stor.flush(); flushErr != nil {
Expand Down Expand Up @@ -3584,7 +3584,7 @@ func (s *stateManager) LastFinalizedHeight() (proto.Height, error) {
}
calculatedFinalizedHeight := proto.CalculateLastFinalizedHeight(currentHeight)

storedFinalizedHeight, err := s.stor.finalizations.newest()
storedFinalizedHeight, err := s.stor.finalizations.newestVisible(currentHeight)
if err == nil {
// Finalization must never lag behind the protocol lower bound (currentHeight - 100).
if storedFinalizedHeight < calculatedFinalizedHeight {
Expand Down
54 changes: 49 additions & 5 deletions pkg/state/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ func TestRollbackToHeight_AutoRollbackKeepsFinalizationCounter(t *testing.T) {
// This guarantees the record is normally removed by rollback and must be restored in auto mode.
topBlockID, err := manager.HeightToBlockID(importHeight)
require.NoError(t, err)
err = manager.stor.finalizations.store(finalizedHeight, topBlockID)
err = manager.stor.finalizations.store(finalizedHeight, importHeight, topBlockID)
require.NoError(t, err)
err = manager.flush()
require.NoError(t, err)
Expand Down Expand Up @@ -295,7 +295,7 @@ func TestRollbackToHeight_ManualRollbackChangesFinalizationCounter(t *testing.T)
// Same setup as auto rollback test: finalization record will be removed by rollback.
topBlockID, err := manager.HeightToBlockID(importHeight)
require.NoError(t, err)
err = manager.stor.finalizations.store(finalizedHeight, topBlockID)
err = manager.stor.finalizations.store(finalizedHeight, importHeight, topBlockID)
require.NoError(t, err)
err = manager.flush()
require.NoError(t, err)
Expand Down Expand Up @@ -333,7 +333,7 @@ func TestRollbackToHeight_AutoRollbackBelowFinalizedHeightFails(t *testing.T) {

topBlockID, err := manager.HeightToBlockID(importHeight)
require.NoError(t, err)
err = manager.stor.finalizations.store(finalizedHeight, topBlockID)
err = manager.stor.finalizations.store(finalizedHeight, importHeight, topBlockID)
require.NoError(t, err)
err = manager.flush()
require.NoError(t, err)
Expand Down Expand Up @@ -365,7 +365,7 @@ func TestRollbackToHeight_ManualRollbackBelowFinalizedHeightSucceeds(t *testing.

topBlockID, err := manager.HeightToBlockID(importHeight)
require.NoError(t, err)
err = manager.stor.finalizations.store(finalizedHeight, topBlockID)
err = manager.stor.finalizations.store(finalizedHeight, importHeight, topBlockID)
require.NoError(t, err)
err = manager.flush()
require.NoError(t, err)
Expand Down Expand Up @@ -399,7 +399,7 @@ func TestLastFinalizedHeight_UsesProtocolLowerBoundWhenStoredValueIsStale(t *tes

topBlockID, err := manager.HeightToBlockID(importHeight)
require.NoError(t, err)
err = manager.stor.finalizations.store(finalizedHeight, topBlockID)
err = manager.stor.finalizations.store(finalizedHeight, importHeight, topBlockID)
require.NoError(t, err)
err = manager.flush()
require.NoError(t, err)
Expand All @@ -409,6 +409,50 @@ func TestLastFinalizedHeight_UsesProtocolLowerBoundWhenStoredValueIsStale(t *tes
require.Equal(t, proto.Height(importHeight-100), lastFinalizedHeight)
}

func TestLastFinalizedHeight_ExposesPreFinalizationOnlyAtNPlus2(t *testing.T) {
blocksPath, err := blocksPath()
require.NoError(t, err)

sets := settings.MustMainNetSettings()
sets.PreactivatedFeatures = append(sets.PreactivatedFeatures, int16(settings.DeterministicFinality))
manager := newTestStateManager(t, true, DefaultTestingStateParams(), sets)

const (
importHeight = 15
finalizedHeight = 14
)
err = importer.ApplyFromFile(
t.Context(),
importer.ImportParams{Schema: sets.AddressSchemeCharacter, BlockchainPath: blocksPath, LightNodeMode: false},
manager, importHeight-1, 1,
)
require.NoError(t, err)

topBlockID, err := manager.HeightToBlockID(importHeight)
require.NoError(t, err)
err = manager.stor.finalizations.store(finalizedHeight, importHeight, topBlockID)
require.NoError(t, err)
err = manager.flush()
require.NoError(t, err)

// At height N+1, only finalized (not pre-finalized) value is visible.
atNPlus1, err := manager.LastFinalizedHeight()
require.NoError(t, err)
require.Equal(t, proto.Height(1), atNPlus1)

// After one more block (N+2), pre-finalization is promoted and becomes visible.
err = importer.ApplyFromFile(
t.Context(),
importer.ImportParams{Schema: sets.AddressSchemeCharacter, BlockchainPath: blocksPath, LightNodeMode: false},
manager, importHeight, importHeight,
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second ApplyFromFile call imports importHeight (15) blocks starting at startHeight=importHeight (15), advancing the chain from height 15 to approximately height 30. However, only a single additional block (reaching height 16) is necessary for the condition currentHeight >= pendingHeight+2 = 14+2 = 16 to be satisfied and the pending finalization to become visible. The comment "After one more block (N+2)" is misleading since 15 blocks are imported rather than one. Consider using startHeight=importHeight and nBlocks=1 instead to precisely test the N+2 threshold.

Suggested change
manager, importHeight, importHeight,
manager, importHeight, 1,

Copilot uses AI. Check for mistakes.
)
require.NoError(t, err)

atNPlus2, err := manager.LastFinalizedHeight()
require.NoError(t, err)
require.Equal(t, proto.Height(finalizedHeight), atNPlus2)
}

func TestStateIntegrated(t *testing.T) {
dir, err := getLocalDir()
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion pkg/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ type EndorsementPool interface {
GetEndorsers() []bls.PublicKey
SaveBlockGenerator(blockGenerator *crypto.PublicKey)
BlockGenerator() (crypto.PublicKey, error)
FormFinalization(lastFinalizationHeight proto.Height) (proto.FinalizationVoting, error)
FormFinalization() (proto.FinalizationVoting, error)
Verify() (bool, error)
Len() int
CleanAll()
Expand Down
Loading