From dc95351fba9a8ffb8987816b03bf7debefa2c799 Mon Sep 17 00:00:00 2001 From: Shaun Colley <79477620+smcio@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:52:56 +0100 Subject: [PATCH] fix(replay): install future epoch metadata at boundaries Epoch transitions cache stakes for the leader-schedule epoch, which can differ from the epoch being entered. Prepare both epochs, deduplicate them when equal, and install every newly cached validator set after the boundary. Add regression coverage for standard, equal, and non-standard schedule offsets, including future validator-set installation. --- pkg/replay/alpenglow_engine.go | 9 ++ pkg/replay/block.go | 9 +- pkg/replay/epoch.go | 35 ++++- pkg/replay/epoch_transition_metadata_test.go | 138 +++++++++++++++++++ 4 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 pkg/replay/epoch_transition_metadata_test.go diff --git a/pkg/replay/alpenglow_engine.go b/pkg/replay/alpenglow_engine.go index b72a9bc6..d073ecc8 100644 --- a/pkg/replay/alpenglow_engine.go +++ b/pkg/replay/alpenglow_engine.go @@ -53,6 +53,15 @@ func installAlpenglowValidatorSet(consensusEngine consensusengine.Engine, epoch } } +// installEpochTransitionAlpenglowValidatorSets installs the validator sets +// needed both to execute the newly entered epoch and to verify the future epoch +// whose leader schedule was prepared at the boundary. +func installEpochTransitionAlpenglowValidatorSets(consensusEngine consensusengine.Engine, newEpoch, leaderScheduleEpoch uint64) { + for _, targetEpoch := range epochTransitionTargetEpochs(newEpoch, leaderScheduleEpoch) { + installAlpenglowValidatorSet(consensusEngine, targetEpoch) + } +} + // installCachedAlpenglowValidatorSets installs validator sets for every cached // epoch (so certs spanning an epoch boundary verify), plus the current epoch. func installCachedAlpenglowValidatorSets(consensusEngine consensusengine.Engine, currentEpoch uint64) { diff --git a/pkg/replay/block.go b/pkg/replay/block.go index 2064bf56..e02692ab 100644 --- a/pkg/replay/block.go +++ b/pkg/replay/block.go @@ -2560,9 +2560,14 @@ func ReplayBlocks( } } - // Alpenglow: reinstall the BLS validator set for the new epoch. + // Alpenglow: install both the newly entered epoch and the future + // leader-schedule epoch prepared at this boundary. if consensusEngine != nil { - installAlpenglowValidatorSet(consensusEngine, currentEpoch) + installEpochTransitionAlpenglowValidatorSets( + consensusEngine, + currentEpoch, + epochSchedule.LeaderScheduleEpoch(block.Slot), + ) } if len(newlyActivatedFeatures) != 0 { diff --git a/pkg/replay/epoch.go b/pkg/replay/epoch.go index 8b947693..79129103 100644 --- a/pkg/replay/epoch.go +++ b/pkg/replay/epoch.go @@ -46,6 +46,33 @@ func requireDurableEpochBoundaryParent(boundarySlot, parentSlot, durableSlot uin boundarySlot, parentSlot, durableSlot) } +// epochTransitionTargetEpochs returns every epoch whose metadata must be ready +// when crossing a boundary. The epoch being entered and the epoch whose leader +// schedule is selected at that slot are usually different, but some epoch +// schedules make them the same. +func epochTransitionTargetEpochs(newEpoch, leaderScheduleEpoch uint64) []uint64 { + epochs := []uint64{newEpoch} + if leaderScheduleEpoch != newEpoch { + epochs = append(epochs, leaderScheduleEpoch) + } + return epochs +} + +func prepareEpochTransitionLeaderSchedules(newEpoch, leaderScheduleEpoch uint64, epochSchedule *sealevel.SysvarEpochSchedule, logsDir string) error { + for _, targetEpoch := range epochTransitionTargetEpochs(newEpoch, leaderScheduleEpoch) { + var err error + if len(global.EpochStakesVoteAccts(targetEpoch)) > 0 { + _, err = PrepareLeaderScheduleLocal(targetEpoch, epochSchedule, logsDir) + } else { + _, err = PrepareLeaderScheduleLocalFromVoteCache(targetEpoch, epochSchedule, logsDir) + } + if err != nil { + return fmt.Errorf("prepare leader schedule for epoch %d at epoch transition: %w", targetEpoch, err) + } + } + return nil +} + // newReplayCtx creates a new ReplayCtx, preferring values from resumeState if available. // This ensures resume uses fresh values instead of potentially stale manifest data. func newReplayCtx(mithrilState *state.MithrilState, resumeState *ResumeState) (*ReplayCtx, error) { @@ -241,13 +268,7 @@ func handleEpochTransition(acctsDb *accountsdb.AccountsDb, partitionedEpochRewar t2 := time.Now() if global.ManageLeaderSchedule() { - if len(global.EpochStakesVoteAccts(newEpoch)) > 0 { - _, err = PrepareLeaderScheduleLocal(newEpoch, epochSchedule, "") - } else { - _, err = PrepareLeaderScheduleLocalFromVoteCache(newEpoch, epochSchedule, "") - } - - if err != nil { + if err = prepareEpochTransitionLeaderSchedules(newEpoch, leaderScheduleEpoch, epochSchedule, ""); err != nil { panic(err) } diff --git a/pkg/replay/epoch_transition_metadata_test.go b/pkg/replay/epoch_transition_metadata_test.go new file mode 100644 index 00000000..3f06f52f --- /dev/null +++ b/pkg/replay/epoch_transition_metadata_test.go @@ -0,0 +1,138 @@ +package replay + +import ( + "crypto/ed25519" + "testing" + + "github.com/Overclock-Validator/mithril/pkg/alpenglow" + "github.com/Overclock-Validator/mithril/pkg/epochstakes" + "github.com/Overclock-Validator/mithril/pkg/global" + "github.com/Overclock-Validator/mithril/pkg/sealevel" + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/require" +) + +func TestEpochTransitionTargetEpochs(t *testing.T) { + const ( + slotsPerEpoch = uint64(54_000) + boundarySlot = uint64(110 * slotsPerEpoch) + ) + + tests := []struct { + name string + offset uint64 + want []uint64 + }{ + { + name: "standard one epoch offset", + offset: slotsPerEpoch, + want: []uint64{110, 111}, + }, + { + name: "same epoch is deduplicated", + offset: slotsPerEpoch / 2, + want: []uint64{110}, + }, + { + name: "nonstandard offset is not hardcoded", + offset: 2 * slotsPerEpoch, + want: []uint64{110, 112}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + epochSchedule := &sealevel.SysvarEpochSchedule{ + SlotsPerEpoch: slotsPerEpoch, + LeaderScheduleSlotOffset: tt.offset, + } + newEpoch := epochSchedule.GetEpoch(boundarySlot) + leaderScheduleEpoch := epochSchedule.LeaderScheduleEpoch(boundarySlot) + + require.Equal(t, tt.want, epochTransitionTargetEpochs(newEpoch, leaderScheduleEpoch)) + }) + } +} + +func TestPrepareEpochTransitionLeaderSchedulesPreparesFutureEpoch(t *testing.T) { + const slotsPerEpoch = uint64(54_000) + epochSchedule := &sealevel.SysvarEpochSchedule{ + SlotsPerEpoch: slotsPerEpoch, + LeaderScheduleSlotOffset: slotsPerEpoch, + } + _, nodePubkey := seedEpochTransitionValidator(t, 110, 111) + + err := prepareEpochTransitionLeaderSchedules(110, 111, epochSchedule, t.TempDir()) + require.NoError(t, err) + + for _, epoch := range []uint64{110, 111} { + leader, ok := global.LeaderForSlot(epochSchedule.FirstSlotInEpoch(epoch)) + require.Truef(t, ok, "leader schedule for epoch %d was not installed", epoch) + require.Equal(t, nodePubkey, leader) + } +} + +type epochTransitionValidatorSetRecorder struct { + fakeEngine + installed []alpenglow.ValidatorSet +} + +func (r *epochTransitionValidatorSetRecorder) SetAlpenglowValidatorSet(set alpenglow.ValidatorSet) error { + r.installed = append(r.installed, set) + return nil +} + +func TestInstallEpochTransitionAlpenglowValidatorSetsIncludesFuture(t *testing.T) { + seedEpochTransitionValidator(t, 110, 111) + recorder := new(epochTransitionValidatorSetRecorder) + + installEpochTransitionAlpenglowValidatorSets(recorder, 110, 111) + + require.Len(t, recorder.installed, 2) + require.Equal(t, uint64(110), recorder.installed[0].Epoch) + require.Equal(t, uint64(111), recorder.installed[1].Epoch) +} + +func TestInstallEpochTransitionAlpenglowValidatorSetsDeduplicatesEqualEpoch(t *testing.T) { + seedEpochTransitionValidator(t, 110) + recorder := new(epochTransitionValidatorSetRecorder) + + installEpochTransitionAlpenglowValidatorSets(recorder, 110, 110) + + require.Len(t, recorder.installed, 1) + require.Equal(t, uint64(110), recorder.installed[0].Epoch) +} + +func seedEpochTransitionValidator(t *testing.T, epochs ...uint64) (solana.PublicKey, solana.PublicKey) { + t.Helper() + + var voteAccount, nodePubkey solana.PublicKey + voteAccount[0] = 0xa1 + nodePubkey[0] = 0xb1 + + privateKey := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + signer, err := alpenglow.DeriveBLSSigner(privateKey) + require.NoError(t, err) + compressed := signer.PublicKeyCompressed() + + for _, epoch := range epochs { + global.ClearEpochStakes(epoch) + global.SetLeaderScheduleForEpoch(epoch, nil) + + blsPubkey := compressed + global.PutEpochStakesEntry(epoch, voteAccount, 100, &epochstakes.VoteAccount{ + NodePubkey: nodePubkey, + BlsPubkeyCompressed: &blsPubkey, + }) + global.PutEpochTotalStake(epoch, 100) + } + + t.Cleanup(func() { + for _, epoch := range epochs { + global.ClearEpochStakes(epoch) + global.SetLeaderScheduleForEpoch(epoch, nil) + } + }) + + return voteAccount, nodePubkey +}