From cc9adfed78c9c5c36df70a7a6c2bec8e01377016 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:34:25 +0300 Subject: [PATCH 1/3] core/tracker: fix inclusion trim underflow at chain start The inclusion checker's slot arithmetic underflows on fresh chains: before genesis the wall-clock offset is negative, during the first InclCheckLag slots the checked slot goes negative, and until wall slot InclCheckLag+InclMissedLag the trim slot goes negative. The trim underflow makes Trim(huge) delete every pending submission and report each fresh duty as "duty not included on-chain", failing all early proposals on every fresh-genesis network. Skip ticks until a slot is old enough to check, and only trim once a slot can actually be declared missed. --- core/tracker/inclusion.go | 18 +++++++- core/tracker/inclusion_internal_test.go | 59 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/core/tracker/inclusion.go b/core/tracker/inclusion.go index 70c9f1960..a73577c9a 100644 --- a/core/tracker/inclusion.go +++ b/core/tracker/inclusion.go @@ -598,7 +598,15 @@ func (a *InclusionChecker) Run(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - slot := uint64(time.Since(a.genesis)/a.slotDuration) - InclCheckLag + // Skip until a slot is old enough to check: the unsigned + // arithmetic below underflows before genesis (negative elapsed + // time) and during the first InclCheckLag slots. + sinceGenesis := time.Since(a.genesis) + if sinceGenesis < a.slotDuration*InclCheckLag { + continue + } + + slot := uint64(sinceGenesis/a.slotDuration) - InclCheckLag if checkedSlot == slot { continue } @@ -656,7 +664,13 @@ func (a *InclusionChecker) Run(ctx context.Context) { } checkedSlot = slot - a.core.Trim(ctx, slot-InclMissedLag) + + // Only trim once a slot is old enough to be declared missed: + // slot-InclMissedLag underflows otherwise, and Trim would then + // report every pending submission as not included on-chain. + if slot >= InclMissedLag { + a.core.Trim(ctx, slot-InclMissedLag) + } } } } diff --git a/core/tracker/inclusion_internal_test.go b/core/tracker/inclusion_internal_test.go index 84911f7e3..4dc989d9d 100644 --- a/core/tracker/inclusion_internal_test.go +++ b/core/tracker/inclusion_internal_test.go @@ -6,7 +6,9 @@ import ( "context" "math/rand" "slices" + "sync" "testing" + "time" "github.com/OffchainLabs/go-bitfield" eth2api "github.com/attestantio/go-eth2-client/api" @@ -579,3 +581,60 @@ func TestInclusion404Handling(t *testing.T) { require.Error(t, err, "checkBlockAndAtts should return an error for non-404 errors") }) } + +// TestRunNoFalseMissesAtChainStart is a regression test for the first slots +// after genesis (wall slots below InclCheckLag+InclMissedLag): the trim-slot +// arithmetic underflowed, so Trim deleted every pending submission and +// reported fresh duties as "duty not included on-chain". +func TestRunNoFalseMissesAtChainStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + bmock, err := beaconmock.New(ctx) + require.NoError(t, err) + + var ( + mu sync.Mutex + missed []core.Duty + ) + + incl := &inclusionCore{ + missedFunc: func(ctx context.Context, sub submission) { + mu.Lock() + defer mu.Unlock() + + missed = append(missed, sub.Duty) + }, + trackerInclFunc: func(duty core.Duty, key core.PubKey, data core.SignedData, err error) {}, + submissions: make(map[subkey]submission), + beaconCommittees: make(map[eth2p0.Slot][]*eth2v1.BeaconCommittee), + } + + // Pin the wall slot to 10 for the whole test (inside the underflow + // window): genesis 10 slots ago with a slot duration far longer than + // the test. + checker := &InclusionChecker{ + core: incl, + eth2Cl: bmock, + genesis: time.Now().Add(-10 * time.Hour), + slotDuration: time.Hour, + checkBlockFunc: incl.CheckBlock, + } + + // A pending proposal from slot 3: too recent to be declared missed. + block := testutil.RandomDenebVersionedSignedProposal() + coreBlock, err := core.NewVersionedSignedProposal(block) + require.NoError(t, err) + require.NoError(t, incl.Submitted(core.NewProposerDuty(3), "", coreBlock, 0)) + + go checker.Run(ctx) + + // Let the checker tick at least twice (1s ticker). + time.Sleep(2500 * time.Millisecond) + cancel() + + mu.Lock() + defer mu.Unlock() + + require.Empty(t, missed, "fresh submissions must not be reported missed right after genesis") +} From c7faa91b54191d370fdca00b4a7d8927f0f32451 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:03:17 +0300 Subject: [PATCH 2/3] core/tracker: check the genesis slot and deflake the regression test The zero-valued checkedSlot skipped slot 0 as already-checked; start from a MaxUint64 sentinel instead. Synchronize the chain-start regression test on the first inclusion check rather than sleeping, and pin it to the genesis slot so it covers both guards. --- core/tracker/inclusion.go | 5 ++- core/tracker/inclusion_internal_test.go | 46 +++++++++++++++++-------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/core/tracker/inclusion.go b/core/tracker/inclusion.go index a73577c9a..8d4c77b32 100644 --- a/core/tracker/inclusion.go +++ b/core/tracker/inclusion.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "maps" + "math" "net/http" "slices" "strconv" @@ -589,7 +590,9 @@ func (a *InclusionChecker) Run(ctx context.Context) { } var ( - checkedSlot uint64 + // MaxUint64 sentinel so the first computed slot (0 at chain start) + // is not skipped as already-checked. + checkedSlot = uint64(math.MaxUint64) attesterDuties []*eth2v1.AttesterDuty ) diff --git a/core/tracker/inclusion_internal_test.go b/core/tracker/inclusion_internal_test.go index 4dc989d9d..387aa3546 100644 --- a/core/tracker/inclusion_internal_test.go +++ b/core/tracker/inclusion_internal_test.go @@ -583,9 +583,10 @@ func TestInclusion404Handling(t *testing.T) { } // TestRunNoFalseMissesAtChainStart is a regression test for the first slots -// after genesis (wall slots below InclCheckLag+InclMissedLag): the trim-slot -// arithmetic underflowed, so Trim deleted every pending submission and -// reported fresh duties as "duty not included on-chain". +// after genesis: the trim-slot arithmetic underflowed, so Trim deleted every +// pending submission and reported fresh duties as "duty not included +// on-chain", and the zero-valued checkedSlot skipped the genesis slot's +// inclusion check entirely. func TestRunNoFalseMissesAtChainStart(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -610,15 +611,22 @@ func TestRunNoFalseMissesAtChainStart(t *testing.T) { beaconCommittees: make(map[eth2p0.Slot][]*eth2v1.BeaconCommittee), } - // Pin the wall slot to 10 for the whole test (inside the underflow - // window): genesis 10 slots ago with a slot duration far longer than - // the test. + // Pin the checked slot to 0 for the whole test: genesis InclCheckLag + // slots ago with a slot duration far longer than the test. + checked := make(chan uint64, 1) checker := &InclusionChecker{ - core: incl, - eth2Cl: bmock, - genesis: time.Now().Add(-10 * time.Hour), - slotDuration: time.Hour, - checkBlockFunc: incl.CheckBlock, + core: incl, + eth2Cl: bmock, + genesis: time.Now().Add(-InclCheckLag * time.Hour), + slotDuration: time.Hour, + checkBlockFunc: func(ctx context.Context, slot uint64, found bool) { + incl.CheckBlock(ctx, slot, found) + + select { + case checked <- slot: + default: + } + }, } // A pending proposal from slot 3: too recent to be declared missed. @@ -627,11 +635,21 @@ func TestRunNoFalseMissesAtChainStart(t *testing.T) { require.NoError(t, err) require.NoError(t, incl.Submitted(core.NewProposerDuty(3), "", coreBlock, 0)) - go checker.Run(ctx) + var wg sync.WaitGroup + + wg.Go(func() { + checker.Run(ctx) + }) + + select { + case slot := <-checked: + require.Zero(t, slot, "the genesis slot must be checked, not skipped as already-checked") + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the genesis slot inclusion check") + } - // Let the checker tick at least twice (1s ticker). - time.Sleep(2500 * time.Millisecond) cancel() + wg.Wait() // Run exits only after the tick completes, including any trim. mu.Lock() defer mu.Unlock() From 4660635dae6b4aacb42c9e816cec4d760bb0f8d7 Mon Sep 17 00:00:00 2001 From: kalo <24719519+KaloyanTanev@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:24:32 +0300 Subject: [PATCH 3/3] core/tracker: pin attestation-inclusion feature off in regression test The test wires only checkBlockFunc; disable the attestation_inclusion feature explicitly so Run cannot take the nil checkBlockAndAttsFunc path regardless of test ordering. --- core/tracker/inclusion_internal_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/tracker/inclusion_internal_test.go b/core/tracker/inclusion_internal_test.go index 387aa3546..df1c6ae59 100644 --- a/core/tracker/inclusion_internal_test.go +++ b/core/tracker/inclusion_internal_test.go @@ -588,6 +588,9 @@ func TestInclusion404Handling(t *testing.T) { // on-chain", and the zero-valued checkedSlot skipped the genesis slot's // inclusion check entirely. func TestRunNoFalseMissesAtChainStart(t *testing.T) { + // Run must take the checkBlockFunc path below, not checkBlockAndAttsFunc. + featureset.DisableForT(t, featureset.AttestationInclusion) + ctx, cancel := context.WithCancel(context.Background()) defer cancel()