From 937ae205a006571d5662c0f5e2f9e5e0b3d77ea9 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 20:38:51 -0700 Subject: [PATCH 01/98] feat(autobahn): introduce EpochTrio for multi-epoch verification (CON-358) Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/autobahn/types/epoch.go | 3 + sei-tendermint/autobahn/types/epoch_trio.go | 65 ++++++ .../autobahn/types/epoch_trio_test.go | 188 ++++++++++++++++ .../internal/autobahn/avail/block_votes.go | 33 ++- .../internal/autobahn/avail/conv_test.go | 2 +- .../internal/autobahn/avail/inner.go | 77 +++++-- .../internal/autobahn/avail/inner_test.go | 147 ++++++++----- .../internal/autobahn/avail/state.go | 123 ++++++++--- .../internal/autobahn/avail/state_test.go | 51 ++++- .../internal/autobahn/consensus/inner.go | 49 ++++- .../internal/autobahn/consensus/inner_test.go | 205 +++++++++--------- .../consensus/persist/commitqcs_test.go | 26 +-- .../consensus/persist/fullcommitqcs_test.go | 20 +- .../consensus/persist/globalblocks_test.go | 22 +- .../internal/autobahn/consensus/state.go | 9 +- .../internal/autobahn/consensus/state_test.go | 16 +- .../internal/autobahn/data/state.go | 98 ++++++--- .../internal/autobahn/data/state_test.go | 72 ++++-- .../internal/autobahn/epoch/registry.go | 139 +++++++++--- .../internal/autobahn/epoch/registry_test.go | 160 +++++++++++++- .../internal/autobahn/epoch/testonly.go | 51 ++++- .../autobahn/producer/mempool_test.go | 2 +- .../internal/p2p/giga/avail_test.go | 2 +- .../internal/p2p/giga/consensus_test.go | 2 +- sei-tendermint/internal/p2p/giga/data_test.go | 2 +- .../internal/p2p/giga_router_common.go | 17 +- .../internal/p2p/giga_router_fullnode.go | 1 + .../internal/p2p/giga_router_validator.go | 3 +- 28 files changed, 1199 insertions(+), 386 deletions(-) create mode 100644 sei-tendermint/autobahn/types/epoch_trio.go create mode 100644 sei-tendermint/autobahn/types/epoch_trio_test.go diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index 3ab13df54a..51fce553b8 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -22,6 +22,9 @@ func OpenRoadRange() RoadRange { return RoadRange{First: 0, Last: utils.Max[Road // Has reports whether idx falls within this range (inclusive on both ends). func (r RoadRange) Has(idx RoadIndex) bool { return idx >= r.First && idx <= r.Last } +// MidPoint returns the road index at the midpoint of the range. +func (r RoadRange) MidPoint() RoadIndex { return r.First + (r.Last-r.First)/2 } + // Epoch holds the complete context for a single epoch. // Retrieved from the local Registry; never transmitted on the wire. type Epoch struct { diff --git a/sei-tendermint/autobahn/types/epoch_trio.go b/sei-tendermint/autobahn/types/epoch_trio.go new file mode 100644 index 0000000000..a07cba8805 --- /dev/null +++ b/sei-tendermint/autobahn/types/epoch_trio.go @@ -0,0 +1,65 @@ +package types + +import "fmt" + +// EpochTrio is a view of up to three consecutive epochs centered on Current. +// Prev and Next may be nil. Updated only when an AppQC advances into a new epoch. +type EpochTrio struct { + Prev *Epoch // nil if Current is epoch 0 or predecessor not yet seeded + Current *Epoch + Next *Epoch // nil if successor not yet seeded +} + +// all returns the three epochs in priority order: Current first, then Next, then Prev. +// This ensures EpochForRoad matches the most-likely epoch first and prevents an +// open-range Prev from shadowing Current or Next. +func (w EpochTrio) all() [3]*Epoch { + return [3]*Epoch{w.Current, w.Next, w.Prev} +} + +// EpochForRoad returns the epoch whose road range contains roadIdx. +func (w EpochTrio) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { + for _, ep := range w.all() { + if ep != nil && ep.RoadRange().Has(roadIdx) { + return ep, nil + } + } + return nil, fmt.Errorf("road %d not in window %v", roadIdx, w) +} + +// CurrentAndNextLanes returns the union of lanes for the current and next epochs. +func (w EpochTrio) CurrentAndNextLanes() map[LaneID]struct{} { + lanes := make(map[LaneID]struct{}) + for _, ep := range [2]*Epoch{w.Current, w.Next} { + if ep != nil { + for lane := range ep.Committee().Lanes().All() { + lanes[lane] = struct{}{} + } + } + } + return lanes +} + +// VerifyInWindow calls fn against Current and Next only, skipping Prev. +// Use for votes and blocks, which must belong to the current or upcoming epoch. +func (w EpochTrio) VerifyInWindow(fn func(*Committee) error) (*Epoch, error) { + for _, ep := range [2]*Epoch{w.Current, w.Next} { + if ep != nil && fn(ep.Committee()) == nil { + return ep, nil + } + } + return nil, fmt.Errorf("not accepted by current or next epoch in %v", w) +} + +// String returns a compact description of the epoch indices in the window. +func (w EpochTrio) String() string { + s := "epochs [" + sep := "" + for _, ep := range w.all() { + if ep != nil { + s += fmt.Sprintf("%s%d", sep, ep.EpochIndex()) + sep = ", " + } + } + return s + "]" +} diff --git a/sei-tendermint/autobahn/types/epoch_trio_test.go b/sei-tendermint/autobahn/types/epoch_trio_test.go new file mode 100644 index 0000000000..8fd4d1c8b7 --- /dev/null +++ b/sei-tendermint/autobahn/types/epoch_trio_test.go @@ -0,0 +1,188 @@ +package types_test + +import ( + "errors" + "testing" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// makeThreeEpochs returns three consecutive epochs with non-overlapping road ranges. +func makeThreeEpochs(t *testing.T) (prev, current, next *types.Epoch, keys []types.SecretKey) { + t.Helper() + rng := utils.TestRng() + sks := utils.GenSliceN(rng, 3, types.GenSecretKey) + weights := map[types.PublicKey]uint64{} + for _, sk := range sks { + weights[sk.Public()] = 1 + } + committee := utils.OrPanic1(types.NewCommittee(weights)) + prev = types.NewEpoch(0, types.RoadRange{First: 0, Last: 99}, utils.GenTimestamp(rng), committee, 1) + current = types.NewEpoch(1, types.RoadRange{First: 100, Last: 199}, utils.GenTimestamp(rng), committee, 101) + next = types.NewEpoch(2, types.RoadRange{First: 200, Last: 299}, utils.GenTimestamp(rng), committee, 201) + return prev, current, next, sks +} + +// --- EpochForRoad --- + +func TestEpochForRoad_HitsCurrentEpoch(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: next} + ep, err := w.EpochForRoad(150) + if err != nil { + t.Fatalf("EpochForRoad(150): %v", err) + } + if ep.EpochIndex() != current.EpochIndex() { + t.Fatalf("got epoch %d, want current (%d)", ep.EpochIndex(), current.EpochIndex()) + } +} + +func TestEpochForRoad_HitsPrevEpoch(t *testing.T) { + prev, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Prev: prev, Current: current, Next: next} + ep, err := w.EpochForRoad(50) + if err != nil { + t.Fatalf("EpochForRoad(50): %v", err) + } + if ep.EpochIndex() != prev.EpochIndex() { + t.Fatalf("got epoch %d, want prev (%d)", ep.EpochIndex(), prev.EpochIndex()) + } +} + +func TestEpochForRoad_HitsNextEpoch(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: next} + ep, err := w.EpochForRoad(250) + if err != nil { + t.Fatalf("EpochForRoad(250): %v", err) + } + if ep.EpochIndex() != next.EpochIndex() { + t.Fatalf("got epoch %d, want next (%d)", ep.EpochIndex(), next.EpochIndex()) + } +} + +func TestEpochForRoad_OutsideWindowReturnsError(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: next} + if _, err := w.EpochForRoad(999); err == nil { + t.Fatal("EpochForRoad(999) expected error, got nil") + } +} + +func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { + // Regression: genesis epoch used to have OpenRoadRange (Last=MaxUint64). + // EpochForRoad iterated [Prev, Current, Next], so Prev.Has(any) was always + // true — every lookup returned epoch 0 instead of the correct epoch. + rng := utils.TestRng() + sks := utils.GenSliceN(rng, 3, types.GenSecretKey) + weights := map[types.PublicKey]uint64{} + for _, sk := range sks { + weights[sk.Public()] = 1 + } + committee := utils.OrPanic1(types.NewCommittee(weights)) + openEpoch := types.NewEpoch(0, types.OpenRoadRange(), utils.GenTimestamp(rng), committee, 1) + current := types.NewEpoch(1, types.RoadRange{First: 100, Last: 199}, utils.GenTimestamp(rng), committee, 101) + w := types.EpochTrio{Prev: openEpoch, Current: current} + ep, err := w.EpochForRoad(150) + if err != nil { + t.Fatalf("EpochForRoad(150): %v", err) + } + if ep.EpochIndex() != current.EpochIndex() { + t.Fatalf("got epoch %d (Prev with OpenRoadRange masked Current), want current (%d)", + ep.EpochIndex(), current.EpochIndex()) + } +} + +func TestEpochForRoad_NilPrevSkipped(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Prev: nil, Current: current, Next: next} + // Road 50 belongs to prev, which is nil — should return error, not panic. + if _, err := w.EpochForRoad(50); err == nil { + t.Fatal("EpochForRoad(50) with nil Prev expected error, got nil") + } +} + +// --- CurrentAndNextLanes --- + +func TestCurrentAndNextLanes_UnionOfBoth(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: next} + lanes := w.CurrentAndNextLanes() + for lane := range current.Committee().Lanes().All() { + if _, ok := lanes[lane]; !ok { + t.Fatalf("lane %v from Current missing from result", lane) + } + } + for lane := range next.Committee().Lanes().All() { + if _, ok := lanes[lane]; !ok { + t.Fatalf("lane %v from Next missing from result", lane) + } + } +} + +func TestCurrentAndNextLanes_NilNextOmitted(t *testing.T) { + _, current, _, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: nil} + lanes := w.CurrentAndNextLanes() + for lane := range current.Committee().Lanes().All() { + if _, ok := lanes[lane]; !ok { + t.Fatalf("lane %v from Current missing from result", lane) + } + } +} + +// --- VerifyInWindow --- + +func TestVerifyInWindow_MatchesCurrent(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: next} + ep, err := w.VerifyInWindow(func(c *types.Committee) error { return nil }) + if err != nil { + t.Fatalf("VerifyInWindow: %v", err) + } + if ep.EpochIndex() != current.EpochIndex() { + t.Fatalf("got epoch %d, want current (%d)", ep.EpochIndex(), current.EpochIndex()) + } +} + +func TestVerifyInWindow_FallsBackToNext(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: next} + first := true + ep, err := w.VerifyInWindow(func(*types.Committee) error { + if first { + first = false + return errors.New("reject") + } + return nil + }) + if err != nil { + t.Fatalf("VerifyInWindow: %v", err) + } + if ep.EpochIndex() != next.EpochIndex() { + t.Fatalf("got epoch %d, want next (%d)", ep.EpochIndex(), next.EpochIndex()) + } +} + +func TestVerifyInWindow_NoneMatchReturnsError(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: next} + if _, err := w.VerifyInWindow(func(*types.Committee) error { return errors.New("reject") }); err == nil { + t.Fatal("VerifyInWindow expected error when fn rejects all, got nil") + } +} + +func TestVerifyInWindow_SkipsPrev(t *testing.T) { + prev, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Prev: prev, Current: current, Next: next} + callCount := 0 + _, _ = w.VerifyInWindow(func(*types.Committee) error { + callCount++ + return errors.New("reject") + }) + // fn should be called for Current and Next only — not Prev. + if callCount != 2 { + t.Fatalf("VerifyInWindow called fn %d times, want 2 (Current + Next only)", callCount) + } +} diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index ba84945e1e..ff66bdbc56 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -16,8 +16,9 @@ func newBlockVotes() blockVotes { } } +// pushVote may store the vote for the current and next Epoch, but only +// accumulates weight for currentEpoch. // Returns true iff a new QC has been constructed. -// TODO: handle epoch transitions — weight must be counted per-epoch committee once multi-epoch is wired up. func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) (*types.LaneQC, bool) { c := ep.Committee() k := vote.Key() @@ -41,3 +42,33 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot } return nil, false } + +// reweight recalculates weights and vote lists for all stored votes using +// newEpoch's committee. Called when the epoch advances so that votes from +// validators who were in the next epoch are now counted. Returns true if any +// block hash newly reached quorum under the new committee. +func (bv blockVotes) reweight(newEpoch *types.Epoch) bool { + c := newEpoch.Committee() + for _, set := range bv.byHash { + set.weight = 0 + set.votes = set.votes[:0] + } + quorumReached := false + for k, vote := range bv.byKey { + w := c.Weight(k) + if w == 0 { + continue + } + h := vote.Msg().Header().Hash() + set := bv.byHash[h] + if set.weight >= c.LaneQuorum() { + continue + } + set.weight += w + set.votes = append(set.votes, vote) + if set.weight >= c.LaneQuorum() { + quorumReached = true + } + } + return quorumReached +} diff --git a/sei-tendermint/internal/autobahn/avail/conv_test.go b/sei-tendermint/internal/autobahn/avail/conv_test.go index ca753e5ee5..e83e8b6a99 100644 --- a/sei-tendermint/internal/autobahn/avail/conv_test.go +++ b/sei-tendermint/internal/autobahn/avail/conv_test.go @@ -12,7 +12,7 @@ import ( func TestPruneAnchorConv(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index f25d8bf461..8a08200387 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -16,7 +16,6 @@ import ( // BlockPersister creates lane WALs lazily inside MaybePruneAndPersistLane, but the new // member must also appear in inner.blocks before the next persist cycle. type inner struct { - epoch *types.Epoch latestAppQC utils.Option[*types.AppQC] latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] appVotes *queue[types.GlobalBlockNumber, appVotes] @@ -57,16 +56,16 @@ type loadedAvailState struct { blocks map[types.LaneID][]persist.LoadedBlock } -func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inner, error) { +func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailState]) (*inner, error) { + lanes := startEpochTrio.CurrentAndNextLanes() votes := map[types.LaneID]*queue[types.BlockNumber, blockVotes]{} blocks := map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{} - for lane := range epoch.Committee().Lanes().All() { + for lane := range lanes { votes[lane] = newQueue[types.BlockNumber, blockVotes]() blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() } i := &inner{ - epoch: epoch, latestAppQC: utils.None[*types.AppQC](), latestCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), appVotes: newQueue[types.GlobalBlockNumber, appVotes](), @@ -76,7 +75,7 @@ func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inne nextBlockToPersist: make(map[types.LaneID]types.BlockNumber, len(votes)), persistedBlockStart: make(map[types.LaneID]types.BlockNumber, len(votes)), } - i.appVotes.prune(epoch.FirstBlock()) + i.appVotes.prune(startEpochTrio.Current.FirstBlock()) l, ok := loaded.Get() if !ok { @@ -91,8 +90,7 @@ func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inne slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), slog.Uint64("globalNumber", uint64(anchor.AppQC.Proposal().GlobalNumber())), ) - // TODO: use the committee of the anchor's epoch once epoch transitions are wired up. - if _, err := i.prune(epoch.Committee(), anchor.AppQC, anchor.CommitQC); err != nil { + if _, err := i.prune(anchor.AppQC, anchor.CommitQC); err != nil { return nil, fmt.Errorf("prune: %w", err) } for lane := range i.blocks { @@ -115,14 +113,20 @@ func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inne i.latestCommitQC.Store(utils.Some(i.commitQCs.q[i.commitQCs.next-1])) } - // Restore persisted blocks. Since the anchor is persisted first and - // blocks are written sequentially per lane, gaps, parent-hash - // mismatches, and over-capacity indicate corruption or a bug. + // Restore persisted blocks. Create queues on demand for any lane present + // in the WAL — lanes outside the current epoch will be pruned by + // reweightForNextEpoch in NewState if a boundary was crossed. for lane, bs := range l.blocks { - q, ok := i.blocks[lane] - if !ok || len(bs) == 0 { + if len(bs) == 0 { continue } + if _, ok := i.blocks[lane]; !ok { + i.blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() + i.votes[lane] = newQueue[types.BlockNumber, blockVotes]() + i.nextBlockToPersist[lane] = 0 + i.persistedBlockStart[lane] = 0 + } + q := i.blocks[lane] var lastHash types.BlockHeaderHash for j, b := range bs { if q.Len() >= BlocksPerLane { @@ -147,20 +151,57 @@ func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inne return i, nil } -// TODO: filter votes per-epoch committee once epoch transitions are wired up. -func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) (*types.LaneQC, bool) { - c := i.epoch.Committee() +func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, trio types.EpochTrio) (*types.LaneQC, bool) { + quorum := trio.Current.Committee().LaneQuorum() for _, byHash := range i.votes[lane].q[n].byHash { - if byHash.weight >= c.LaneQuorum() { - return types.NewLaneQC(byHash.votes[:]), true + if byHash.weight >= quorum { + return types.NewLaneQC(byHash.votes), true } } return nil, false } +// reweightForNextEpoch initializes queues for any new lanes in nextTrio.Current and +// recalculates vote weights across all lanes using the new committee. +// Returns true if any block newly reached quorum under the new committee. +func (i *inner) reweightForNextEpoch(nextTrio types.EpochTrio) bool { + activeLanes := nextTrio.CurrentAndNextLanes() + for lane := range nextTrio.Current.Committee().Lanes().All() { + if _, ok := i.blocks[lane]; !ok { + i.blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() + } + if _, ok := i.votes[lane]; !ok { + i.votes[lane] = newQueue[types.BlockNumber, blockVotes]() + } + if _, ok := i.nextBlockToPersist[lane]; !ok { + i.nextBlockToPersist[lane] = 0 + } + if _, ok := i.persistedBlockStart[lane]; !ok { + i.persistedBlockStart[lane] = 0 + } + } + for lane := range i.blocks { + if _, ok := activeLanes[lane]; !ok { + delete(i.blocks, lane) + delete(i.votes, lane) + delete(i.nextBlockToPersist, lane) + delete(i.persistedBlockStart, lane) + } + } + quorumReached := false + for _, voteQueue := range i.votes { + for n := voteQueue.first; n < voteQueue.next; n++ { + if voteQueue.q[n].reweight(nextTrio.Current) { + quorumReached = true + } + } + } + return quorumReached +} + // prune advances the state to account for a new AppQC/CommitQC pair. // Returns true if pruning occurred, false if the QC was stale. -func (i *inner) prune(c *types.Committee, appQC *types.AppQC, commitQC *types.CommitQC) (bool, error) { +func (i *inner) prune(appQC *types.AppQC, commitQC *types.CommitQC) (bool, error) { idx := appQC.Proposal().RoadIndex() if idx != commitQC.Proposal().Index() { return false, fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", idx, commitQC.Proposal().Index()) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index afdee71d9a..ef6247a26a 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -14,7 +14,9 @@ import ( func TestPruneMismatchedIndices(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) + ep, err := registry.EpochAt(0) + require.NoError(t, err) makeCommitQC := func(prev utils.Option[*types.CommitQC]) *types.CommitQC { l := keys[0].Public() @@ -23,7 +25,7 @@ func TestPruneMismatchedIndices(t *testing.T) { lqcs := map[types.LaneID]*types.LaneQC{ l: types.NewLaneQC(makeLaneVotes(keys, b.Header())), } - return makeCommitQC(registry.LatestEpoch(), keys, prev, lqcs, utils.None[*types.AppQC]()) + return makeCommitQC(ep, keys, prev, lqcs, utils.None[*types.AppQC]()) } makeAppQC := func(qcForRange *types.CommitQC, qcForIndex *types.CommitQC) *types.AppQC { gr := qcForRange.GlobalRange() @@ -49,15 +51,14 @@ func TestPruneMismatchedIndices(t *testing.T) { state, err = NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) for inner := range state.inner.Lock() { - _, err := inner.prune(registry.LatestEpoch().Committee(), makeAppQC(qc1, qc0), qc1) + _, err := inner.prune(makeAppQC(qc1, qc0), qc1) require.Error(t, err, "good range, bad index should fail") require.False(t, inner.latestAppQC.IsPresent(), "latestAppQC should not have been updated") - _, err = inner.prune(registry.LatestEpoch().Committee(), makeAppQC(qc1, qc1), qc1) + _, err = inner.prune(makeAppQC(qc1, qc1), qc1) require.NoError(t, err, "good range, good index should succeed") } } -// testSignedBlock creates a signed lane proposal for a given lane, block number, and parent hash. func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber, parent types.BlockHeaderHash, rng utils.Rng) *types.Signed[*types.LaneProposal] { block := types.NewBlock(lane, n, parent, types.GenPayload(rng)) return types.Sign(key, types.NewLaneProposal(block)) @@ -65,9 +66,9 @@ func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber func TestNewInnerFreshStart(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) + registry, _, _ := epoch.GenRegistry(rng, 4) - i, err := newInner(registry.LatestEpoch(), utils.None[*loadedAvailState]()) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.None[*loadedAvailState]()) require.NoError(t, err) require.False(t, i.latestAppQC.IsPresent()) @@ -86,7 +87,7 @@ func TestNewInnerFreshStart(t *testing.T) { func TestDecodePruneAnchorIncomplete(t *testing.T) { rng := utils.TestRng() - _, keys := epoch.GenRegistry(rng, 4) + _, keys, _ := epoch.GenRegistry(rng, 4) appProposal := types.NewAppProposal(42, 5, types.GenAppHash(rng), 0) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) @@ -100,11 +101,11 @@ func TestDecodePruneAnchorIncomplete(t *testing.T) { func TestNewInnerLoadedNoAnchor(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) + registry, _, _ := epoch.GenRegistry(rng, 4) loaded := &loadedAvailState{} - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // No anchor loaded, app votes should start at the registry's first block. @@ -115,7 +116,7 @@ func TestNewInnerLoadedNoAnchor(t *testing.T) { func TestNewInnerLoadedBlocksContiguous(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() // Build 3 contiguous blocks: 0, 1, 2. @@ -131,7 +132,7 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) q := i.blocks[lane] @@ -153,14 +154,14 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() loaded := &loadedAvailState{ blocks: map[types.LaneID][]persist.LoadedBlock{lane: {}}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) q := i.blocks[lane] @@ -170,7 +171,7 @@ func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) unknownKey := types.GenSecretKey(rng) unknownLane := unknownKey.Public() @@ -180,7 +181,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{unknownLane: {{Number: 0, Proposal: b}}}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) for lane := range registry.LatestEpoch().Committee().Lanes().All() { @@ -193,7 +194,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane0 := keys[0].Public() lane1 := keys[1].Public() @@ -217,7 +218,7 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs0, lane1: bs1}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) q0 := i.blocks[lane0] @@ -235,7 +236,7 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) // Create 3 sequential CommitQCs. qcs := make([]*types.CommitQC, 3) @@ -254,7 +255,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // Without anchor, commitQCs.first = 0. All 3 should be restored. @@ -272,7 +273,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) // AppQC at road index 2. roadIdx := types.RoadIndex(2) @@ -300,7 +301,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // latestAppQC should be set by prune. @@ -324,7 +325,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { func TestNewInnerLoadedAllThree(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() // AppQC at road index 2. @@ -361,7 +362,7 @@ func TestNewInnerLoadedAllThree(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // AppQC restored. @@ -387,10 +388,10 @@ func TestNewInnerLoadedAllThree(t *testing.T) { func TestPruneAdvancesNextBlockToPersist(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() - i, err := newInner(registry.LatestEpoch(), utils.None[*loadedAvailState]()) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.None[*loadedAvailState]()) require.NoError(t, err) // Push blocks 0-4 on one lane. @@ -430,7 +431,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { appProposal := types.NewAppProposal(10, 2, types.GenAppHash(rng), 0) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - updated, err := i.prune(registry.LatestEpoch().Committee(), appQC, qcs[2]) + updated, err := i.prune(appQC, qcs[2]) require.NoError(t, err) require.True(t, updated) @@ -446,7 +447,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) // Build 6 CommitQCs (indices 0-5). Anchor at index 5. // All stale commitQCs (0-4) were already filtered by loadPersistedState, @@ -465,7 +466,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[5]}), } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune() pushes the anchor's CommitQC into the queue. @@ -476,7 +477,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) // Simulate crash between anchor write and CommitQC file write: // anchor has AppQC@3 + CommitQC@3, but no CommitQC files on disk. @@ -494,7 +495,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune() should push the anchor's CommitQC into the queue. @@ -516,7 +517,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() @@ -537,20 +538,20 @@ func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) + registry, _, _ := epoch.GenRegistry(rng, 4) loaded := &loadedAvailState{ commitQCs: nil, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) @@ -561,7 +562,7 @@ func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) // Simulate crash scenario: disk had stale QCs [0,1,2] and a new QC at // index 10. loadPersistedState pre-filters stale entries, so newInner @@ -585,7 +586,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // Only QC@10 loaded. @@ -605,7 +606,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) // Build 6 CommitQCs (0-5). Anchor at index 3. // Loaded list includes stale entries [1, 2] below the anchor plus [3, 4, 5]. @@ -634,7 +635,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune(3) pushes QC@3 (next=4). Indices 1,2,3 are skipped. 4,5 pushed. @@ -647,7 +648,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) // Anchor at index 2. Loaded commitQCs are [2, 3, 5] — gap at 4. // After prune(2), next=3. Index 2 is skipped, 3 pushed (next=4), @@ -673,14 +674,14 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() // Blocks 3, 4, 6, 7 with no anchor — queue starts at 0, so block 3 @@ -697,14 +698,14 @@ func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() // Build blocks 0, 1 with correct chaining, then block 2 with wrong parent. @@ -725,14 +726,14 @@ func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "parent hash mismatch") } func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() // Build BlocksPerLane + 5 contiguous blocks — more than the lane capacity. @@ -751,14 +752,14 @@ func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "exceeds capacity") } func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) initialBlock := types.GlobalBlockNumber(0) // Build CommitQCs 0-2. @@ -794,7 +795,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune() should advance block queue first to the prune anchor's lane range. @@ -807,7 +808,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) initialBlock := types.GlobalBlockNumber(0) // Build CommitQCs 0-2. @@ -830,7 +831,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { }, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune(appQC@1, pruneQC@1) should advance commitQCs.first to 1. @@ -838,3 +839,47 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { // CommitQCs 1 and 2 should still be loaded. require.Equal(t, types.RoadIndex(3), i.commitQCs.next) } + +func TestReweightForNextEpoch_AddsAndRemovesLanes(t *testing.T) { + rng := utils.TestRng() + registry, _, _ := epoch.GenRegistry(rng, 4) + trio := utils.OrPanic1(registry.TrioAt(0)) + + i, err := newInner(trio, utils.None[*loadedAvailState]()) + require.NoError(t, err) + + // All current lanes are present after construction. + for lane := range trio.Current.Committee().Lanes().All() { + require.Contains(t, i.blocks, lane, "lane %v missing after newInner", lane) + } + + // Add a bogus extra lane directly. + var realLane types.LaneID + for l := range trio.Current.Committee().Lanes().All() { + realLane = l + break + } + bogusSK := types.GenSecretKey(rng) + bogusLane := bogusSK.Public() + i.blocks[bogusLane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() + i.votes[bogusLane] = newQueue[types.BlockNumber, blockVotes]() + i.nextBlockToPersist[bogusLane] = 0 + i.persistedBlockStart[bogusLane] = 0 + + // reweightForNextEpoch removes the bogus lane and keeps the real one. + i.reweightForNextEpoch(trio) + require.NotContains(t, i.blocks, bogusLane, "decommissioned lane still present") + require.Contains(t, i.blocks, realLane, "active lane removed incorrectly") +} + +func TestReweightForNextEpoch_EmptyQueuesReturnsFalse(t *testing.T) { + rng := utils.TestRng() + registry, _, _ := epoch.GenRegistry(rng, 4) + trio := utils.OrPanic1(registry.TrioAt(0)) + + i, err := newInner(trio, utils.None[*loadedAvailState]()) + require.NoError(t, err) + + // No votes in any queue; reweight should not signal quorum. + require.False(t, i.reweightForNextEpoch(trio)) +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 913d6fbfb4..5a727acf6e 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "sync/atomic" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail/metrics" @@ -31,9 +32,10 @@ const BlocksPerLane = 3 * types.MaxLaneRangeInProposal // to trigger internal pruning, which allows it to manage memory independently // of the main consensus loop. type State struct { - key types.SecretKey - data *data.State - inner utils.Watch[*inner] + key types.SecretKey + data *data.State + inner utils.Watch[*inner] + epochTrio atomic.Pointer[types.EpochTrio] // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. @@ -156,17 +158,42 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } - ep := data.Registry().LatestEpoch() - inner, err := newInner(ep, loaded) + // TODO: in production a node should always restart from a snapshot rather than + // genesis; starting from road 0 is only correct on the very first boot. + startRoadIdx := types.RoadIndex(0) + if ls, ok := loaded.Get(); ok { + if anchor, ok := ls.pruneAnchor.Get(); ok { + startRoadIdx = anchor.CommitQC.Proposal().Index() + } + } + startTrio, err := data.Registry().TrioAt(startRoadIdx) + if err != nil { + return nil, fmt.Errorf("TrioAt(%d): %w", startRoadIdx, err) + } + inner, err := newInner(startTrio, loaded) if err != nil { return nil, err } + finalTrio := startTrio + if inner.commitQCs.next > startTrio.Current.RoadRange().Last { + nextTrio, err := data.Registry().TrioAt(inner.commitQCs.next) + if err != nil { + return nil, fmt.Errorf("TrioAt(%d): %w", inner.commitQCs.next, err) + } + inner.reweightForNextEpoch(nextTrio) + finalTrio = nextTrio + } // Truncate WAL entries below the prune anchor that were filtered out by // loadPersistedState. if ls, ok := loaded.Get(); ok { if anchor, ok := ls.pruneAnchor.Get(); ok { - for lane := range ep.Committee().Lanes().All() { + anchorTrio, err := data.Registry().TrioAt(anchor.CommitQC.Proposal().Index()) + if err != nil { + return nil, fmt.Errorf("TrioAt(%d): %w", anchor.CommitQC.Proposal().Index(), err) + } + lanes := anchorTrio.CurrentAndNextLanes() + for lane := range lanes { if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { return nil, fmt.Errorf("prune stale block WAL entries: %w", err) } @@ -177,12 +204,14 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin } } - return &State{ + s := &State{ key: key, data: data, inner: utils.NewWatch(inner), persisters: pers, - }, nil + } + s.epochTrio.Store(&finalTrio) + return s, nil } func (s *State) FirstCommitQC() types.RoadIndex { @@ -262,9 +291,12 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - ep, ok := s.data.Registry().EpochByIndex(qc.Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.Proposal().EpochIndex()) + // epochTrio is updated atomically inside the lock at epoch boundaries, so + // this outside-lock read sees either the current epoch or the just-rotated + // one — both are valid for verifying a CommitQC at idx. + ep, err := s.epochTrio.Load().EpochForRoad(idx) + if err != nil { + return fmt.Errorf("EpochAt(%d): %w", idx, err) } if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) @@ -273,8 +305,20 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { if idx != inner.commitQCs.next { return nil } - if qc.Proposal().EpochIndex() != inner.epoch.EpochIndex() { - return fmt.Errorf("commitQC epoch_index %d != current epoch %d", qc.Proposal().EpochIndex(), inner.epoch.EpochIndex()) + // At an epoch boundary, initialize lanes for the next epoch. + // The next epoch must already be registered: AdvanceIfNeeded pre-generates + // it during epoch N, well before any CommitQC at the boundary. A miss here + // is a registry bug — fail loudly rather than continuing with stale state. + if idx == s.epochTrio.Load().Current.RoadRange().Last { + nextTrio, err := s.data.Registry().TrioAt(idx + 1) + if err != nil { + return fmt.Errorf("TrioAt(%d): %w", idx+1, err) + } + quorumReached := inner.reweightForNextEpoch(nextTrio) + s.epochTrio.Store(&nextTrio) + if quorumReached { + ctrl.Updated() + } } inner.commitQCs.pushBack(qc) metrics.ObserveCommitQC(qc) @@ -289,12 +333,12 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // PushAppVote pushes an AppVote to the state. func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]) error { - ep, ok := s.data.Registry().EpochByIndex(v.Msg().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", v.Msg().Proposal().EpochIndex()) + idx := v.Msg().Proposal().RoadIndex() + ep, err := s.epochTrio.Load().EpochForRoad(idx) + if err != nil { + return fmt.Errorf("EpochAt(%d): %w", idx, err) } committee := ep.Committee() - idx := v.Msg().Proposal().RoadIndex() if err := v.VerifySig(committee); err != nil { return fmt.Errorf("v.VerifySig(): %w", err) } @@ -322,12 +366,13 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if !ok { return nil } - updated, err := inner.prune(committee, appQC, qc) + updated, err := inner.prune(appQC, qc) if err != nil { return err } if updated { ctrl.Updated() + s.data.Registry().AdvanceIfNeeded(appQC) } } return nil @@ -342,9 +387,9 @@ func (s *State) PushAppQC(appQC *types.AppQC, commitQC *types.CommitQC) error { return nil } } - ep, ok := s.data.Registry().EpochByIndex(commitQC.Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", commitQC.Proposal().EpochIndex()) + ep, err := s.epochTrio.Load().EpochForRoad(commitQC.Proposal().Index()) + if err != nil { + return fmt.Errorf("EpochAt(%d): %w", commitQC.Proposal().Index(), err) } if err := appQC.Verify(ep.Committee()); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) @@ -363,15 +408,20 @@ func (s *State) PushAppQC(appQC *types.AppQC, commitQC *types.CommitQC) error { if !commitQC.GlobalRange().Has(appQC.Proposal().GlobalNumber()) { return fmt.Errorf("appQC GlobalNumber not in commitQC range") } + var accepted bool for inner, ctrl := range s.inner.Lock() { - updated, err := inner.prune(ep.Committee(), appQC, commitQC) + updated, err := inner.prune(appQC, commitQC) if err != nil { return err } if updated { + accepted = true ctrl.Updated() } } + if accepted { + s.data.Registry().AdvanceIfNeeded(appQC) + } return nil } @@ -412,7 +462,7 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos if p.Key() != h.Lane() { return fmt.Errorf("signer %v does not match lane %v", p.Key(), h.Lane()) } - if _, err := s.data.Registry().VerifyInWindow(func(c *types.Committee) error { + if _, err := s.epochTrio.Load().VerifyInWindow(func(c *types.Committee) error { if err := p.Msg().Verify(c); err != nil { return err } @@ -464,7 +514,7 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos // Waits until the lane has enough capacity for the new vote. // It does NOT wait for the previous votes. func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { - if _, err := s.data.Registry().VerifyInWindow(func(c *types.Committee) error { + if _, err := s.epochTrio.Load().VerifyInWindow(func(c *types.Committee) error { if err := vote.Msg().Verify(c); err != nil { return err } @@ -489,7 +539,10 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - if _, ok := q.q[h.BlockNumber()].pushVote(inner.epoch, vote); ok { + // pushVote accumulates weight using the current epoch's committee. + // Votes from next-epoch validators are stored (byKey) but contribute + // zero weight until reweightForNextEpoch runs at the epoch boundary. + if _, ok := q.q[h.BlockNumber()].pushVote(s.epochTrio.Load().Current, vote); ok { ctrl.Updated() } } @@ -538,9 +591,9 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful } // Collect the headers from the votes. var commitHeaders []*types.BlockHeader - ep, ok := s.data.Registry().EpochByIndex(qc.Proposal().EpochIndex()) - if !ok { - return nil, fmt.Errorf("unknown epoch_index %d", qc.Proposal().EpochIndex()) + ep, err := s.epochTrio.Load().EpochForRoad(qc.Proposal().Index()) + if err != nil { + return nil, fmt.Errorf("EpochAt(%d): %w", qc.Proposal().Index(), err) } for lane := range ep.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) @@ -576,7 +629,7 @@ func (s *State) WaitForLaneQCs( for lane := range ep.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { - if qc, ok := inner.laneQC(lane, first+i); ok { + if qc, ok := inner.laneQC(lane, first+i, *s.epochTrio.Load()); ok { laneQCs[lane] = qc } else { break @@ -650,9 +703,9 @@ func (s *State) Run(ctx context.Context) error { } // Collect the blocks we have locally. - ep, ok := s.data.Registry().EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) + ep, err := s.epochTrio.Load().EpochForRoad(qc.QC().Proposal().Index()) + if err != nil { + return fmt.Errorf("EpochForRoad(%d): %w", qc.QC().Proposal().Index(), err) } c := ep.Committee() var blocks []*types.Block @@ -741,9 +794,9 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { batchLanes[lane] = struct{}{} } if anchor, ok := anchorQC.Get(); ok { - ep, epOK := s.data.Registry().EpochByIndex(anchor.Proposal().EpochIndex()) - if !epOK { - return fmt.Errorf("unknown epoch_index %d", anchor.Proposal().EpochIndex()) + ep, err := s.epochTrio.Load().EpochForRoad(anchor.Proposal().Index()) + if err != nil { + return fmt.Errorf("EpochForRoad(%d): %w", anchor.Proposal().Index(), err) } for lane := range ep.Committee().Lanes().All() { batchLanes[lane] = struct{}{} diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 3558c4a5ad..647562859c 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -89,7 +89,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { t.Helper() ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) committee := registry.LatestEpoch().Committee() if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { @@ -216,7 +216,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { // loadPersistedState (stale entries below the prune anchor are discarded). func TestStateRestartFromPersisted(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -321,7 +321,7 @@ func TestStateRestartFromPersisted(t *testing.T) { func TestStateMismatchedQCs(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() initialBlock := registry.FirstBlock() @@ -380,7 +380,7 @@ func TestStateMismatchedQCs(t *testing.T) { func TestPushBlockRejectsBadParentHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) ds := utils.OrPanic1(data.NewState(&data.Config{ Registry: registry, @@ -405,7 +405,7 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { func TestPushBlockRejectsWrongSigner(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) ds := utils.OrPanic1(data.NewState(&data.Config{ Registry: registry, @@ -423,7 +423,7 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { func TestNewStateWithPersistence(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) initialBlock := types.GlobalBlockNumber(0) t.Run("empty dir loads fresh state", func(t *testing.T) { @@ -816,3 +816,42 @@ func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { return nil })) } + +// TestPushAppQCPreviousEpoch verifies that an AppQC whose road index falls in +// epoch N-1 is accepted when the registry is seeded at epoch N. This exercises +// the path where a late AppQC arrives after an epoch boundary has been crossed. +func TestPushAppQCPreviousEpoch(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 3) + + epochN1, err := registry.EpochAt(0) // epoch 0 (N-1) + require.NoError(t, err) + epochN, err := registry.EpochAt(epoch.EpochLength) // epoch 1 (N) + require.NoError(t, err) + + // Build a CommitQC at the last road of epoch N-1. + lane := keys[0].Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + laneQCs := map[types.LaneID]*types.LaneQC{ + lane: types.NewLaneQC(makeLaneVotes(keys, block.Header())), + } + commitQC := makeCommitQC(epochN1, keys, utils.None[*types.CommitQC](), laneQCs, utils.None[*types.AppQC]()) + + // Build an AppQC referencing that CommitQC — it carries epoch N-1's index. + gr := commitQC.GlobalRange() + appProposal := types.NewAppProposal(gr.First, commitQC.Index(), types.GenAppHash(rng), epochN1.EpochIndex()) + appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) + + // Build a CommitQC at epoch N so the state is "at epoch N". + _ = epochN + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // Push the epoch-N CommitQC first so the state has it, then push the late AppQC. + require.NoError(t, state.PushCommitQC(t.Context(), commitQC)) + require.NoError(t, state.PushAppQC(appQC, commitQC), + "AppQC from epoch N-1 should be accepted when registry has epoch N-1 registered") +} diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index e551613386..f030fa3cd1 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -94,7 +94,8 @@ const innerFile = "inner" type inner struct { persistedInner - epoch *types.Epoch + registry *epoch.Registry + epoch *types.Epoch } // View returns the current view, embedding the epoch's index. @@ -117,17 +118,18 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( persisted = *decoded } - // TODO: when AddEpoch is wired, resolve the epoch from the persisted QC/proposal - // rather than assuming LatestEpoch — otherwise a restart after an epoch transition - // fails validation with an epoch/road mismatch. - ep := registry.LatestEpoch() - if err := persisted.validate(ep); err != nil { + nextViewRoad := types.NextIndexOpt(persisted.CommitQC) + startEpoch, err := registry.EpochAt(nextViewRoad) + if err != nil { + return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) + } + if err := persisted.validate(startEpoch); err != nil { return inner{}, err } logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - return inner{persistedInner: persisted, epoch: ep}, nil + return inner{persistedInner: persisted, registry: registry, epoch: startEpoch}, nil } func (s *State) pushCommitQC(qc *types.CommitQC) error { @@ -143,9 +145,18 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } - // CommitQC advances to new index; clear all state for new view. - // TODO: rotate ep when epoch transitions are wired up. - iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, epoch: i.epoch}) + // CommitQC advances to new index; look up the epoch for the next road. + nextEpIdx := qc.Proposal().EpochIndex() + if qc.Proposal().Index() == i.epoch.RoadRange().Last { + nextEpIdx++ + } + nextEp, err := i.registry.EpochAt(types.RoadIndex(nextEpIdx) * epoch.EpochLength) + if err != nil { + logger.Error("next epoch not in registry at CommitQC boundary", + "epochIndex", nextEpIdx, "road", qc.Proposal().Index()) + return fmt.Errorf("epoch %d not in registry", nextEpIdx) + } + iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, registry: i.registry, epoch: nextEp}) } return nil } @@ -172,7 +183,8 @@ func (s *State) pushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { return nil } // TimeoutQC advances view number; clear votes and prepareQC (stale view). - isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, epoch: i.epoch}) + // Epoch is unchanged: the road index did not advance, so i.epoch carries over. + isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, registry: i.registry, epoch: i.epoch}) } return nil } @@ -197,6 +209,21 @@ func (s *State) pushProposal(ctx context.Context, proposal *types.FullProposal) if i.View() != proposal.View() || i.TimeoutVote.IsPresent() || i.PrepareVote.IsPresent() { return nil } + // At the midpoint of epoch N, gate on epoch N+2 being seeded in the registry. + // C_{e+2} = Committee(end(e)): the execution layer derives the N+2 committee + // from the last block of epoch N and delivers it via AppQC from epoch N+1 roads. + // The AppQC pipeline runs ~one epoch ahead of CommitQC, so by the midpoint of + // epoch N, AdvanceIfNeeded has already processed AppQC from epoch N+1 and seeded + // N+2. This gate is intentional back-pressure: if AppQC is lagging, consensus + // stalls here rather than entering epoch N+1 without a known N+2 committee. + if proposal.Proposal().Msg().Index() == i.epoch.RoadRange().MidPoint() { + if _, err := i.registry.EpochAt(types.RoadIndex(i.epoch.EpochIndex()+2) * epoch.EpochLength); err != nil { + logger.Error("refusing PrepareVote: epoch N+2 not yet seeded at epoch midpoint", + "road", proposal.Proposal().Msg().Index(), + "missing", i.epoch.EpochIndex()+2) + return nil + } + } v := types.Sign(s.cfg.Key, types.NewPrepareVote(proposal.Proposal().Msg())) i.PrepareVote = utils.Some(v) isend.Store(i) diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 6625771c79..01f7598af6 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -48,7 +48,7 @@ func makePrepareQC(keys []types.SecretKey, proposal *types.Proposal) *types.Prep func TestNewInnerEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 1) + registry, _, _ := epoch.GenRegistry(rng, 1) // No data should return empty inner (persistence disabled / fresh start) i, err := newInner(utils.None[*pb.PersistedInner](), registry) require.NoError(t, err) @@ -62,9 +62,9 @@ func TestNewInnerPrepareVote(t *testing.T) { dir := t.TempDir() // Create and persist a prepare vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) vote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, &persistedInner{ @@ -84,9 +84,9 @@ func TestNewInnerCommitVote(t *testing.T) { dir := t.TempDir() // Create and persist a commit vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) vote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -108,7 +108,7 @@ func TestNewInnerTimeoutVote(t *testing.T) { dir := t.TempDir() // Create and persist a timeout vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] vote := types.NewFullTimeoutVote(key, types.View{Index: 0, Number: 0}, utils.None[*types.PrepareQC]()) @@ -129,9 +129,9 @@ func TestNewInnerAllVotes(t *testing.T) { dir := t.TempDir() // Create all vote types at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) commitVote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -157,9 +157,9 @@ func TestNewInnerPartialState(t *testing.T) { dir := t.TempDir() // Only persist prepareVote - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, &persistedInner{ @@ -177,10 +177,10 @@ func TestNewInnerPartialState(t *testing.T) { func TestNewInnerCommitQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create a CommitQC at index 5 - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -206,10 +206,10 @@ func TestNewInnerCommitQC(t *testing.T) { func TestNewInnerTimeoutQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create a CommitQC at index 5 (required for TimeoutQC at index 6) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -240,7 +240,7 @@ func TestNewInnerTimeoutQC(t *testing.T) { func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create TimeoutQC at (0, 2) - no CommitQC needed for index 0 var timeoutVotes []*types.FullTimeoutVote @@ -263,7 +263,7 @@ func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create TimeoutQC at index 6 WITHOUT CommitQC at index 5 var timeoutVotes []*types.FullTimeoutVote @@ -285,10 +285,10 @@ func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -317,10 +317,10 @@ func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 10 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -350,10 +350,10 @@ func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { func TestNewInnerViewSpecValidBothQCs(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -385,10 +385,10 @@ func TestNewInnerViewSpecValidBothQCs(t *testing.T) { func TestNewInnerStaleVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -398,7 +398,7 @@ func TestNewInnerStaleVoteError(t *testing.T) { // Create stale vote at view (3, 0) - before current view (6, 0). // Since inner is persisted atomically, a mismatched view is corrupt. - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) staleVote := types.Sign(keys[0], types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, &persistedInner{ @@ -414,10 +414,10 @@ func TestNewInnerStaleVoteError(t *testing.T) { func TestNewInnerFuturePrepareVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -426,7 +426,7 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future vote at view (10, 0) - ahead of current view (6, 0) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewPrepareVote(futureProposal)) seedPersistedInner(dir, &persistedInner{ @@ -443,10 +443,10 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { func TestNewInnerFutureCommitVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -455,7 +455,7 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future commit vote at view (10, 0) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewCommitVote(futureProposal)) seedPersistedInner(dir, &persistedInner{ @@ -472,10 +472,10 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { func TestNewInnerFutureTimeoutVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -500,10 +500,10 @@ func TestNewInnerFutureTimeoutVoteError(t *testing.T) { func TestNewInnerCurrentViewVoteOk(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -512,7 +512,7 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create vote at exactly current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) currentVote := types.Sign(keys[0], types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, &persistedInner{ @@ -529,14 +529,14 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) // Create CommitQC signed by keys NOT in committee otherKeys := make([]types.SecretKey, 3) for i := range otherKeys { otherKeys[i] = types.GenSecretKey(rng) } - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range otherKeys { @@ -557,10 +557,10 @@ func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -593,10 +593,10 @@ func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -606,7 +606,7 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { // Create vote at current view (6, 0) but signed by key NOT in committee otherKey := types.GenSecretKey(rng) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, &persistedInner{ @@ -623,10 +623,10 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -637,7 +637,7 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { // Create stale vote at (3, 0) signed by key NOT in committee. // Since inner is persisted atomically, a mismatched view is corrupt. otherKey := types.GenSecretKey(rng) - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, &persistedInner{ @@ -653,10 +653,10 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { func TestNewInnerPrepareQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create prepareQC at genesis view (0, 0) - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC(keys, proposal) seedPersistedInner(dir, &persistedInner{ @@ -672,10 +672,10 @@ func TestNewInnerPrepareQC(t *testing.T) { func TestNewInnerStalePrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -685,7 +685,7 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { // Create stale prepareQC at view (3, 0) - before current view (6, 0). // Since inner is persisted atomically, a mismatched view is corrupt. - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) stalePrepareQC := makePrepareQC(keys, staleProposal) seedPersistedInner(dir, &persistedInner{ @@ -701,11 +701,11 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Current view is (0, 0) (no CommitQC or TimeoutQC). // CommitVote requires PrepareQC justification. - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) commitVote := types.Sign(keys[0], types.NewCommitVote(proposal)) seedPersistedInner(dir, &persistedInner{ @@ -720,10 +720,10 @@ func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { func TestNewInnerFuturePrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -732,7 +732,7 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future prepareQC at index 10 (> current 6) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) prepareQC := makePrepareQC(keys, futureProposal) seedPersistedInner(dir, &persistedInner{ @@ -749,10 +749,10 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -761,7 +761,7 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -778,10 +778,10 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -794,7 +794,7 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { for i := range otherKeys { otherKeys[i] = types.GenSecretKey(rng) } - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(otherKeys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -811,11 +811,11 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) voteKey := keys[0] // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -824,7 +824,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -842,7 +842,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { timeoutVote := types.NewFullTimeoutVote(voteKey, currentView, i.PrepareQC) // The timeoutVote should pass verification (which checks prepareQC is correctly included) - err = timeoutVote.Verify(registry.LatestEpoch()) + err = timeoutVote.Verify(utils.OrPanic1(registry.EpochAt(0))) require.NoError(t, err, "timeoutVote with loaded prepareQC should verify") // Verify the loaded prepareQC matches what we persisted @@ -855,62 +855,63 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { // Test that pushTimeoutQC clears stale votes and prepareQC func TestPushTimeoutQCClearsStaleState(t *testing.T) { rng := utils.TestRng() - dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) - // Setup: Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) - qcVote := types.NewCommitVote(qcProposal) + // CommitQC at index 5 -> current view is (6, 0). + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { - qcVotes = append(qcVotes, types.Sign(k, qcVote)) + qcVotes = append(qcVotes, types.Sign(k, types.NewCommitVote(qcProposal))) } commitQC := types.NewCommitQC(qcVotes) - // Setup: Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + // prepareQC and votes at current view (6, 0). + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) - - // Setup: Create votes at current view (6, 0) prepareVote := types.Sign(keys[0], types.NewPrepareVote(currentProposal)) commitVote := types.Sign(keys[0], types.NewCommitVote(currentProposal)) timeoutVote := types.NewFullTimeoutVote(keys[0], types.View{Index: 6, Number: 0}, utils.Some(prepareQC)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareQC: utils.Some(prepareQC), - PrepareVote: utils.Some(prepareVote), - CommitVote: utils.Some(commitVote), - TimeoutVote: utils.Some(timeoutVote), - }) - - // Load initial state and verify everything is present - i, err := loadInner(dir, registry) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + cs, err := newState(&Config{ + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + }, ds, utils.None[persist.Persister[*pb.PersistedInner]](), utils.None[*pb.PersistedInner]()) require.NoError(t, err) - require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") - require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be loaded") - require.True(t, i.CommitVote.IsPresent(), "commitVote should be loaded") - require.True(t, i.TimeoutVote.IsPresent(), "timeoutVote should be loaded") + + // Seed CommitQC so the state is at view (6, 0). + require.NoError(t, cs.pushCommitQC(commitQC)) + + // Inject per-view state directly so we can verify it gets cleared. + for isend := range cs.inner.Lock() { + i := isend.Load() + i.PrepareQC = utils.Some(prepareQC) + i.PrepareVote = utils.Some(prepareVote) + i.CommitVote = utils.Some(commitVote) + i.TimeoutVote = utils.Some(timeoutVote) + isend.Store(i) + } + + i := cs.innerRecv.Load() + require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be present before pushTimeoutQC") require.Equal(t, types.View{Index: 6, Number: 0}, i.View(), "initial view should be (6, 0)") - // Create a TimeoutQC for current view (6, 0) that advances to (6, 1) + // TimeoutQC for (6, 0) advances to (6, 1). var timeoutVotes []*types.FullTimeoutVote for _, k := range keys { timeoutVotes = append(timeoutVotes, types.NewFullTimeoutVote(k, types.View{Index: 6, Number: 0}, utils.Some(prepareQC))) } timeoutQC := types.NewTimeoutQC(timeoutVotes) - // Simulate pushTimeoutQC's Update callback - newInner := inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(timeoutQC)}, epoch: i.epoch} - - // Verify: view advanced to (6, 1) - require.Equal(t, types.View{Index: 6, Number: 1}, newInner.View(), "view should advance to (6, 1)") + ctx := t.Context() + require.NoError(t, cs.pushTimeoutQC(ctx, timeoutQC)) - // Verify: prepareQC and all votes are cleared (they're for old view) - require.False(t, newInner.PrepareQC.IsPresent(), "prepareQC should be cleared") - require.False(t, newInner.PrepareVote.IsPresent(), "prepareVote should be cleared") - require.False(t, newInner.CommitVote.IsPresent(), "commitVote should be cleared") - require.False(t, newInner.TimeoutVote.IsPresent(), "timeoutVote should be cleared") + i = cs.innerRecv.Load() + require.Equal(t, types.View{Index: 6, Number: 1}, i.View(), "view should advance to (6, 1)") + require.False(t, i.PrepareQC.IsPresent(), "prepareQC should be cleared") + require.False(t, i.PrepareVote.IsPresent(), "prepareVote should be cleared") + require.False(t, i.CommitVote.IsPresent(), "commitVote should be cleared") + require.False(t, i.TimeoutVote.IsPresent(), "timeoutVote should be cleared") } // failPersister is a Persister that always returns an error. @@ -922,7 +923,7 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { // Verify that a persist error in runOutputs propagates // and terminates the consensus component (instead of panicking). rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) wantErr := errors.New("disk on fire") diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index a1a0ae5035..eb119b12e4 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -106,7 +106,7 @@ func TestNewCommitQCPersisterEmptyDir(t *testing.T) { func TestPersistCommitQCAndLoad(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -135,7 +135,7 @@ func TestPersistCommitQCAndLoad(t *testing.T) { func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -158,7 +158,7 @@ func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { func TestCommitQCDeleteBeforeZero(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -186,7 +186,7 @@ func TestCommitQCDeleteBeforeZero(t *testing.T) { func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -204,7 +204,7 @@ func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { func TestCommitQCPersistGapRejected(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -223,7 +223,7 @@ func TestCommitQCPersistGapRejected(t *testing.T) { func TestLoadAllDetectsCommitQCGap(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -247,7 +247,7 @@ func TestLoadAllDetectsCommitQCGap(t *testing.T) { func TestNoOpCommitQCPersister(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() qcs := makeSequentialCommitQCs(committee, keys, 11) @@ -281,7 +281,7 @@ func TestNoOpCommitQCPersister(t *testing.T) { func TestCommitQCDeleteBeforePastAll(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -313,7 +313,7 @@ func TestCommitQCDeleteBeforePastAll(t *testing.T) { // must re-establish the cursor so subsequent persists succeed. func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -357,7 +357,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { // re-establishes the cursor for subsequent writes. func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -396,7 +396,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -422,7 +422,7 @@ func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -451,7 +451,7 @@ func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { func TestCommitQCProgressiveDeleteBefore(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() diff --git a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go index 5d21fa5046..b203d29453 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go @@ -47,7 +47,7 @@ func TestNewFullCommitQCPersisterEmptyDir(t *testing.T) { func TestNewFullCommitQCPersisterNoop(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) gp, err := NewFullCommitQCPersister(utils.None[string](), registry.FirstBlock()) @@ -71,7 +71,7 @@ func TestNewFullCommitQCPersisterNoop(t *testing.T) { func TestFullCommitQCPersisterFirstBlockFromWAL(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) wantFirst := qcs[0].QC().GlobalRange().First @@ -92,7 +92,7 @@ func TestFullCommitQCPersisterFirstBlockFromWAL(t *testing.T) { func TestFullCommitQCPersistAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -118,7 +118,7 @@ func TestFullCommitQCPersistAndReload(t *testing.T) { func TestFullCommitQCTruncateAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -145,7 +145,7 @@ func TestFullCommitQCTruncateAndReload(t *testing.T) { func TestFullCommitQCTruncateAll(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -166,7 +166,7 @@ func TestFullCommitQCTruncateAll(t *testing.T) { func TestFullCommitQCDuplicateIgnored(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 2) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -180,7 +180,7 @@ func TestFullCommitQCDuplicateIgnored(t *testing.T) { func TestFullCommitQCGapError(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -195,7 +195,7 @@ func TestFullCommitQCGapError(t *testing.T) { func TestFullCommitQCTruncateBeforeNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -216,7 +216,7 @@ func TestFullCommitQCTruncateBeforeNoop(t *testing.T) { func TestFullCommitQCContinueAfterReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 6) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -244,7 +244,7 @@ func TestFullCommitQCContinueAfterReload(t *testing.T) { func TestFullCommitQCTruncateMidRange(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go index 7a16453364..30a0bc0a7e 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go @@ -29,7 +29,7 @@ func TestNewGlobalBlockPersisterEmptyDir(t *testing.T) { func TestNewGlobalBlockPersisterNoop(t *testing.T) { rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -78,7 +78,7 @@ func TestGlobalBlockPersisterFirstBlockFromWAL(t *testing.T) { func TestGlobalBlockPersistAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -104,7 +104,7 @@ func TestGlobalBlockPersistAndReload(t *testing.T) { func TestGlobalBlockTruncateAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 10) @@ -129,7 +129,7 @@ func TestGlobalBlockTruncateAndReload(t *testing.T) { func TestGlobalBlockTruncateAll(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -152,7 +152,7 @@ func TestGlobalBlockTruncateAll(t *testing.T) { func TestGlobalBlockDuplicateIgnored(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) block := types.GenBlock(rng) @@ -167,7 +167,7 @@ func TestGlobalBlockDuplicateIgnored(t *testing.T) { func TestGlobalBlockGapError(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) block := types.GenBlock(rng) @@ -182,7 +182,7 @@ func TestGlobalBlockGapError(t *testing.T) { func TestGlobalBlockTruncateBeforeNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -204,7 +204,7 @@ func TestGlobalBlockTruncateBeforeNoop(t *testing.T) { func TestGlobalBlockContinueAfterReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 10) @@ -233,7 +233,7 @@ func TestGlobalBlockContinueAfterReload(t *testing.T) { func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -269,7 +269,7 @@ func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { func TestGlobalBlockTruncateAfterNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 3) @@ -287,7 +287,7 @@ func TestGlobalBlockTruncateAfterNoop(t *testing.T) { func TestGlobalBlockTruncateAfterBeforeFirst(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 583ec1f270..9e682d470d 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -89,7 +89,12 @@ func NewState(cfg *Config, data *data.State) (*State, error) { pers = utils.Some(p) persistedData = d } - return newState(cfg, data, pers, persistedData) + s, err := newState(cfg, data, pers, persistedData) + if err != nil { + return nil, err + } + data.Registry().SealSeeding() + return s, nil } // newState is the internal constructor exposed for tests that need to inject @@ -123,7 +128,7 @@ func newState( prepareVotes: utils.NewMutex(newPrepareVotes()), commitVotes: utils.NewMutex(newCommitVotes()), - myView: utils.NewAtomicSend(types.ViewSpec{Epoch: initialInner.epoch}), + myView: utils.NewAtomicSend(types.ViewSpec{CommitQC: initialInner.CommitQC, TimeoutQC: initialInner.TimeoutQC, Epoch: initialInner.epoch}), myProposal: utils.NewAtomicSend(utils.None[*types.FullProposal]()), myPrepareVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqPrepareVote]()), myCommitVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqCommitVote]()), diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index a4ede7d420..dbc741edfe 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -18,7 +18,7 @@ import ( // view timeout (so voteTimeout is only triggered explicitly). // keys[0] is used as the node's signing key. func newTestState(rng utils.Rng) (*State, []types.SecretKey, *epoch.Registry) { - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dataState := utils.OrPanic1(data.NewState( &data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), @@ -86,7 +86,7 @@ func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) - pqc := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0})) + pqc := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0})) if err := s.pushPrepareQC(ctx, pqc); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -116,7 +116,7 @@ func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { // View (0, 0): push PrepareQC for proposal P. view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -173,7 +173,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // View (0, 0): PrepareQC for P. view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC(pqc0): %w", err) } @@ -186,7 +186,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // Reproposal at (0, 1) succeeds — new PrepareQC at view (0, 1). view1 := types.View{Index: 0, Number: 1} - pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view1)) + pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC(pqc1): %w", err) } @@ -230,7 +230,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { // Fresh PrepareQC at (0, 1). view1 := types.View{Index: 0, Number: 1} - pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view1)) + pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -259,7 +259,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { // voteTimeout still inherits the PrepareQC from the persisted TimeoutQC. func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() makeCfg := func() *Config { @@ -277,7 +277,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { } view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) // Session 1: push PrepareQC + TimeoutQC, let runOutputs persist. err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 6311c20c05..99a9cc7854 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sync/atomic" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" @@ -202,12 +203,8 @@ func (i *inner) skipTo(n types.GlobalBlockNumber) { // insertQC verifies and inserts a FullCommitQC into the inner state. // Accepts QCs whose range starts at or before nextQC (partially pruned // prefix is silently skipped). Rejects gaps where gr.First > nextQC. -func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error { - e, ok := registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) - } - if err := qc.Verify(e); err != nil { +func (i *inner) insertQC(qc *types.FullCommitQC, ep *types.Epoch) error { + if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } gr := qc.QC().GlobalRange() @@ -224,7 +221,7 @@ func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error return nil } -// insertBlock inserts a pre-verified block into the inner state. +// insertBlock inserts a block into the inner state. // Requires a QC to already be present for block n. Callers must verify // the block signature before calling. // @@ -232,7 +229,7 @@ func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error // updateNextBlock after inserting one or more blocks. This separation // allows batch insertion (e.g. PushQC inserts multiple blocks, then // advances nextBlock once). -func (i *inner) insertBlock(committee *types.Committee, n types.GlobalBlockNumber, block *types.Block) error { +func (i *inner) insertBlock(n types.GlobalBlockNumber, block *types.Block) error { if n < i.first || n >= i.nextQC { return nil // outside QC range } @@ -283,7 +280,12 @@ type State struct { cfg *Config metrics *metrics.Metrics inner utils.Watch[*inner] - dataWAL *DataWAL + // epochTrio is a snapshot of the registry window centered on the epoch of + // the latest CommitQC. Refreshed inside the PushQC lock whenever a CommitQC + // crosses an epoch boundary. EvmProxy reads epochTrio.Current.Committee() + // to forward user transactions to the correct validators. + epochTrio atomic.Pointer[types.EpochTrio] + dataWAL *DataWAL } // NewState constructs a new data State. @@ -304,7 +306,11 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. for _, qc := range dataWAL.CommitQCs.ConsumeLoaded() { - if err := inner.insertQC(cfg.Registry, qc); err != nil { + ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) + if err != nil { + return nil, fmt.Errorf("load QC from WAL: epoch lookup: %w", err) + } + if err := inner.insertQC(qc, ep); err != nil { return nil, fmt.Errorf("load QC from WAL: %w", err) } } @@ -319,15 +325,14 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { } expectedBlock = lb.Number + 1 qc := inner.qcs[lb.Number] - e, ok := cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return nil, fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) + ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) + if err != nil { + return nil, fmt.Errorf("load block %d from WAL: epoch lookup: %w", lb.Number, err) } - committee := e.Committee() - if err := lb.Block.Verify(committee); err != nil { - return nil, fmt.Errorf("load block %d from WAL: %w", lb.Number, err) + if err := lb.Block.Verify(ep.Committee()); err != nil { + return nil, fmt.Errorf("load block %d from WAL: verify: %w", lb.Number, err) } - if err := inner.insertBlock(committee, lb.Number, lb.Block); err != nil { + if err := inner.insertBlock(lb.Number, lb.Block); err != nil { return nil, fmt.Errorf("load block %d from WAL: %w", lb.Number, err) } } @@ -343,25 +348,43 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { return nil, fmt.Errorf("blocks WAL cursor %d behind inner.nextBlock %d after reconciliation", dataWAL.Blocks.Next(), inner.nextBlock) } - return &State{ + initRoad := types.RoadIndex(0) + if inner.nextQC > 0 { + if lastQC := inner.qcs[inner.nextQC-1]; lastQC != nil { + initRoad = lastQC.QC().Proposal().Index() + } + } + initTrio, err := cfg.Registry.TrioAt(initRoad) + if err != nil { + return nil, fmt.Errorf("init epochTrio: %w", err) + } + s := &State{ cfg: cfg, metrics: metrics.Get(), inner: utils.NewWatch(inner), dataWAL: dataWAL, - }, nil + } + s.epochTrio.Store(&initTrio) + return s, nil } // Registry returns the epoch registry. func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } +// EpochTrio returns the atomic pointer to the current epoch window. +// Callers may read it directly (e.g. EvmProxy) without going through State. +func (s *State) EpochTrio() *atomic.Pointer[types.EpochTrio] { return &s.epochTrio } + // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { - // Wait until QC is needed. - ep, ok := s.cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) + // epochTrio is updated atomically inside the lock at epoch boundaries. + // AdvanceIfNeeded (AppQC path) always fires before any CommitQC for the + // same epoch, so the epoch for this QC is guaranteed present. + ep, err := s.epochTrio.Load().EpochForRoad(qc.QC().Proposal().Index()) + if err != nil { + return err } gr := qc.QC().GlobalRange() needQC, err := func() (bool, error) { @@ -399,6 +422,14 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty inner.qcs[inner.nextQC] = qc inner.nextQC += 1 } + idx := qc.QC().Proposal().Index() + if idx == s.epochTrio.Load().Current.RoadRange().Last { + nextTrio, err := s.cfg.Registry.TrioAt(idx + 1) + if err != nil { + return fmt.Errorf("TrioAt(%d): %w", idx+1, err) + } + s.epochTrio.Store(&nextTrio) + } ctrl.Updated() } if len(byHash) == 0 { @@ -408,13 +439,8 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty for n := max(inner.nextBlock, gr.First); n < min(gr.Next, inner.nextQC); n += 1 { storedQC := inner.qcs[n] storedGR := storedQC.QC().GlobalRange() - storedEp, ok := s.cfg.Registry.EpochByIndex(storedQC.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", storedQC.QC().Proposal().EpochIndex()) - } - storedCommittee := storedEp.Committee() if b, ok := byHash[storedQC.Headers()[n-storedGR.First].Hash()]; ok { - if err := inner.insertBlock(storedCommittee, n, b); err != nil { + if err := inner.insertBlock(n, b); err != nil { return err } } @@ -444,7 +470,7 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC // PushBlock pushes block to the state. // The QC for n must already be present (guaranteed by PushQC ordering). func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block *types.Block) error { - var epochIdx types.EpochIndex + var ep *types.Epoch for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { return n < inner.nextQC }); err != nil { return err @@ -453,11 +479,11 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block // Block arrived after pruning; drop silently so the sender keeps delivering future blocks. return nil } - epochIdx = inner.qcs[n].QC().Proposal().EpochIndex() - } - ep, ok := s.cfg.Registry.EpochByIndex(epochIdx) - if !ok { - return fmt.Errorf("unknown epoch_index %d", epochIdx) + var err error + ep, err = s.epochTrio.Load().EpochForRoad(inner.qcs[n].QC().Proposal().Index()) + if err != nil { + return fmt.Errorf("epoch not in window: %w", err) + } } // Verify outside the lock against the known epoch. if err := block.Verify(ep.Committee()); err != nil { @@ -467,7 +493,7 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block if n < inner.first { return nil } - if err := inner.insertBlock(ep.Committee(), n, block); err != nil { + if err := inner.insertBlock(n, block); err != nil { return err } inner.updateNextBlock(s.metrics) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index a25e4d61aa..718ed5cc8c 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -51,7 +51,7 @@ func snapshot(s *State) Snapshot { func TestState(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -125,7 +125,7 @@ func TestState(t *testing.T) { func TestPushConflictingBadCommitQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) committee := registry.LatestEpoch().Committee() state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -236,7 +236,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := utils.OrPanic1(NewState(&Config{ Registry: registry, }, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) @@ -281,7 +281,7 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { func TestExecution(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -325,7 +325,7 @@ func TestExecution(t *testing.T) { func TestPushBlockAcceptsBlockWithQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -358,7 +358,7 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { func TestGlobalBlockByHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -403,7 +403,7 @@ func TestGlobalBlockByHash(t *testing.T) { func TestReconcileCase1Empty(t *testing.T) { t.Log("Reconcile case 1: Fresh start (empty/empty)") rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() fb := registry.FirstBlock() @@ -423,7 +423,7 @@ func TestReconcileCase1Empty(t *testing.T) { func TestReconcileCase2Corrupted(t *testing.T) { t.Log("Reconcile case 2: QCs lost (corruption), returns error") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() // Persist blocks and QCs normally. @@ -456,7 +456,7 @@ func TestReconcileCase3BlocksLost(t *testing.T) { t.Log("Reconcile case 3: Blocks lost (crash), QCs survive") ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() // First run: populate both WALs. @@ -506,7 +506,7 @@ func TestReconcileCase4Normal(t *testing.T) { t.Log("Reconcile case 4: Normal (a=X, bX)") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) @@ -677,7 +677,7 @@ func TestReconcileCase6QCsAhead(t *testing.T) { t.Log("Reconcile case 6: Prune crash, QCs ahead (a=Y)") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() // Build 2 sequential QCs. @@ -780,7 +780,7 @@ func TestReconcileCase7BlocksTail(t *testing.T) { t.Log("Reconcile case 7: Persist crash, tail truncation with re-push") ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() // Build 2 sequential QCs. @@ -829,7 +829,7 @@ func TestReconcileCase8BlocksBehind(t *testing.T) { t.Log("Reconcile case 8: QCs ahead normal (b s.latest { + s.latest = currentIdx + } } - panic("unreachable") } -// VerifyInWindow calls fn against the latest epoch's committee and returns it if accepted. -// Returns a slice of all matching epochs so callers can skip re-verification for any -// epoch already checked here. -// TODO: expand to neighbor epochs (previous and next) once multi-epoch transitions are wired up. -func (r *Registry) VerifyInWindow(fn func(*types.Committee) error) ([]*types.Epoch, error) { - for s := range r.state.RLock() { - ep := s.m[s.latest] - if err := fn(ep.Committee()); err != nil { - return nil, err - } - return []*types.Epoch{ep}, nil +// TrioAt returns the EpochTrio centered on the epoch containing roadIndex. +// During the seeding phase, missing epochs are auto-generated (same as EpochAt). +// Returns an error if Current or Next is not in the registry after seeding. +func (r *Registry) TrioAt(roadIndex types.RoadIndex) (types.EpochTrio, error) { + centerIdx := types.EpochIndex(roadIndex / EpochLength) + current, err := r.EpochAt(types.RoadIndex(centerIdx) * EpochLength) + if err != nil { + return types.EpochTrio{}, fmt.Errorf("epoch %d (road %d) not in registry", centerIdx, roadIndex) } - panic("unreachable") + next, err := r.EpochAt(types.RoadIndex(centerIdx+1) * EpochLength) + if err != nil { + return types.EpochTrio{}, fmt.Errorf("next epoch %d not in registry", centerIdx+1) + } + trio := types.EpochTrio{Current: current, Next: next} + if centerIdx > 0 { + trio.Prev, _ = r.EpochAt(types.RoadIndex(centerIdx-1) * EpochLength) + } + return trio, nil } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index f5249bcd44..50c3e81c18 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -20,20 +20,164 @@ func makeRegistry(t *testing.T) (*Registry, *types.Committee) { return r, committee } -func TestRegistry_EpochByIndex_UnknownReturnsNotFound(t *testing.T) { +func TestNewRegistry_GenesisEpochBoundedRange(t *testing.T) { r, _ := makeRegistry(t) - if _, ok := r.EpochByIndex(99); ok { - t.Fatal("EpochByIndex(99) returned ok, want not found") + ep, err := r.EpochAt(0) + if err != nil { + t.Fatalf("EpochAt(0): %v", err) + } + rng := ep.RoadRange() + if rng.First != 0 || rng.Last != EpochLength-1 { + t.Fatalf("genesis RoadRange = {%d, %d}, want {0, %d}", rng.First, rng.Last, EpochLength-1) + } +} + +func TestEpochAt_GenesisEpoch(t *testing.T) { + r, _ := makeRegistry(t) + ep, err := r.EpochAt(0) + if err != nil { + t.Fatalf("EpochAt(0) error: %v", err) + } + if ep.EpochIndex() != 0 { + t.Fatalf("EpochAt(0).EpochIndex() = %d, want 0", ep.EpochIndex()) } } -func TestRegistry_EpochByIndex_GenesisFound(t *testing.T) { +func TestEpochAt_WithinGenesisEpoch(t *testing.T) { r, _ := makeRegistry(t) - ep, ok := r.EpochByIndex(0) - if !ok { - t.Fatal("EpochByIndex(0) not found") + ep, err := r.EpochAt(EpochLength - 1) + if err != nil { + t.Fatalf("EpochAt(EpochLength-1) error: %v", err) } if ep.EpochIndex() != 0 { - t.Fatalf("EpochIndex() = %d, want 0", ep.EpochIndex()) + t.Fatalf("EpochAt(EpochLength-1).EpochIndex() = %d, want 0", ep.EpochIndex()) + } +} + +func TestEpochAt_ErrorIfNotRegistered(t *testing.T) { + r, _ := makeRegistry(t) + r.SealSeeding() + _, err := r.EpochAt(EpochLength) + if err == nil { + t.Fatal("EpochAt(EpochLength) expected error for unregistered epoch, got nil") + } +} + +func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { + r, _ := makeRegistry(t) + rng := utils.TestRng() + key := types.GenSecretKey(rng) + proposal := types.NewAppProposal(0, EpochLength-1, types.AppHash{}, types.EpochIndex(0)) + appQC := types.NewAppQC([]*types.Signed[*types.AppVote]{ + types.Sign(key, types.NewAppVote(proposal)), + }) + r.AdvanceIfNeeded(appQC) + ep, err := r.EpochAt(EpochLength) + if err != nil { + t.Fatalf("EpochAt(EpochLength) after AdvanceIfNeeded: %v", err) + } + if ep.EpochIndex() != 1 { + t.Fatalf("EpochAt(EpochLength).EpochIndex() = %d, want 1", ep.EpochIndex()) + } +} + +func TestSeeding_AutoGeneratesEpochs(t *testing.T) { + r, _ := makeRegistry(t) + for _, idx := range []types.EpochIndex{4, 5, 6} { + ep, err := r.EpochAt(types.RoadIndex(idx) * EpochLength) + if err != nil { + t.Fatalf("EpochAt(epoch %d) failed during seeding: %v", idx, err) + } + if ep.EpochIndex() != idx { + t.Fatalf("EpochAt(epoch %d).EpochIndex() = %d, want %d", idx, ep.EpochIndex(), idx) + } + } +} + +func TestSeeding_DoesNotOverwriteExisting(t *testing.T) { + r, _ := makeRegistry(t) + genesis, _ := r.EpochAt(0) + after, _ := r.EpochAt(0) + if genesis != after { + t.Fatal("seeding phase overwrote existing genesis epoch") + } +} + +func TestAdvanceIfNeeded_AdvancesLatest(t *testing.T) { + r, _ := makeRegistry(t) + rng := utils.TestRng() + key := types.GenSecretKey(rng) + proposal := types.NewAppProposal(0, EpochLength-1, types.AppHash{}, types.EpochIndex(0)) + appQC := types.NewAppQC([]*types.Signed[*types.AppVote]{ + types.Sign(key, types.NewAppVote(proposal)), + }) + r.AdvanceIfNeeded(appQC) + if latest := r.LatestEpoch().EpochIndex(); latest != 0 { + t.Fatalf("LatestEpoch().EpochIndex() = %d after AdvanceIfNeeded in epoch 0, want 0", latest) + } +} + +func TestSeeding_LatestNotAdvanced(t *testing.T) { + r, _ := makeRegistry(t) + _, _ = r.EpochAt(5 * EpochLength) + latest := r.LatestEpoch() + if latest.EpochIndex() != 0 { + t.Fatalf("LatestEpoch().EpochIndex() = %d after seeding, want 0", latest.EpochIndex()) + } +} + +func TestSealSeeding_BlocksAutoGenerate(t *testing.T) { + r, _ := makeRegistry(t) + r.SealSeeding() + _, err := r.EpochAt(5 * EpochLength) + if err == nil { + t.Fatal("EpochAt should return error after SealSeeding for unregistered epoch") + } +} + +// --- TrioAt --- + +func TestTrioAt_GenesisEpoch(t *testing.T) { + r, _ := makeRegistry(t) + trio, err := r.TrioAt(0) + if err != nil { + t.Fatalf("TrioAt(0) error: %v", err) + } + if trio.Prev != nil { + t.Fatalf("TrioAt(0).Prev = %v, want nil for epoch 0", trio.Prev) + } + if trio.Current == nil || trio.Current.EpochIndex() != 0 { + t.Fatalf("TrioAt(0).Current.EpochIndex() wrong, want 0") + } + if trio.Next == nil || trio.Next.EpochIndex() != 1 { + t.Fatalf("TrioAt(0).Next.EpochIndex() = %v, want 1", trio.Next) + } +} + +func TestTrioAt_MiddleEpoch(t *testing.T) { + r, _ := makeRegistry(t) + // During seeding, TrioAt auto-generates any missing neighbor epochs. + trio, err := r.TrioAt(2 * EpochLength) + if err != nil { + t.Fatalf("TrioAt(epoch 2) error: %v", err) + } + if trio.Prev == nil || trio.Prev.EpochIndex() != 1 { + t.Fatalf("TrioAt(epoch 2).Prev.EpochIndex() wrong, want 1") + } + if trio.Current == nil || trio.Current.EpochIndex() != 2 { + t.Fatalf("TrioAt(epoch 2).Current.EpochIndex() wrong, want 2") + } + if trio.Next == nil || trio.Next.EpochIndex() != 3 { + t.Fatalf("TrioAt(epoch 2).Next.EpochIndex() wrong, want 3") + } +} + +func TestTrioAt_ErrorWhenNextMissingAfterSealing(t *testing.T) { + r, _ := makeRegistry(t) + r.SealSeeding() + // epoch 0 is present but epoch 1 is not — TrioAt should error. + _, err := r.TrioAt(0) + if err == nil { + t.Fatal("TrioAt(0) expected error when Next epoch not registered after sealing, got nil") } } diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index c02099160f..d90c773e05 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -7,10 +7,40 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// GenRegistry generates a random Registry of the given committee size. -// Returns the generated secret keys as well. +// LatestEpoch returns the most recently activated epoch. For use in tests only. +func (r *Registry) LatestEpoch() *types.Epoch { + for s := range r.state.RLock() { + return s.m[s.latest] + } + panic("unreachable") +} + +// GenRegistry generates a random Registry of the given committee size, +// starting at a random epoch index (0–1). Seeds the neighboring epochs +// so the window covers [startEpoch-1, startEpoch, startEpoch+1]. +// Returns the registry, secret keys, and the starting epoch index. +// Intended for use in tests only. +func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.EpochIndex) { + sks := utils.GenSliceN(rng, size, types.GenSecretKey) + weights := map[types.PublicKey]uint64{} + for _, sk := range sks { + weights[sk.Public()] = 1000 + uint64(rng.Intn(1000)) //nolint:gosec + } + committee := utils.OrPanic1(types.NewCommittee(weights)) + firstBlock := types.GenGlobalBlockNumber(rng) % 1000000 + // Limit to {0, 1}: GenRegistryAt for either value always includes epoch 0 + // ([0,1] or [0,1,2]), so tests that build CommitQC chains from road index 0 + // can still look up epoch 0 in the window. Higher values would require all + // such tests to anchor their chains at startEpoch*EpochLength. + startEpoch := types.EpochIndex(rng.Intn(2)) //nolint:gosec + r := makeRegistryAt(committee, firstBlock, startEpoch) + return r, sks, startEpoch +} + +// GenRegistryAt generates a Registry of the given committee size centered on startEpoch. +// Seeds [startEpoch-1, startEpoch, startEpoch+1] so TrioAt(startEpoch*EpochLength) works. // Intended for use in tests only. -func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey) { +func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Registry, []types.SecretKey) { sks := utils.GenSliceN(rng, size, types.GenSecretKey) weights := map[types.PublicKey]uint64{} for _, sk := range sks { @@ -18,6 +48,19 @@ func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey) { } committee := utils.OrPanic1(types.NewCommittee(weights)) firstBlock := types.GenGlobalBlockNumber(rng) % 1000000 + return makeRegistryAt(committee, firstBlock, startEpoch), sks +} + +func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumber, startEpoch types.EpochIndex) *Registry { registry := utils.OrPanic1(NewRegistry(committee, firstBlock, time.Now())) - return registry, sks + for s := range registry.state.Lock() { + if startEpoch > 0 { + utils.OrPanic1(registry.makeEpoch(s, startEpoch-1)) + } + // Always seed startEpoch itself (no-op when startEpoch==0, genesis already exists). + utils.OrPanic1(registry.makeEpoch(s, startEpoch)) + utils.OrPanic1(registry.makeEpoch(s, startEpoch+1)) + s.latest = startEpoch + } + return registry } diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 8961595683..25f4475eb0 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -230,7 +230,7 @@ func (env *testEnv) Run(ctx context.Context) error { } func newTestEnv(rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) dataState := utils.OrPanic1(data.NewState( &data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 038c0abf07..0d429e4c97 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -17,7 +17,7 @@ import ( func TestAvailClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() env := newTestEnv(registry) var nodes []*testNode diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index a80fa5aa00..6cd33adac2 100644 --- a/sei-tendermint/internal/p2p/giga/consensus_test.go +++ b/sei-tendermint/internal/p2p/giga/consensus_test.go @@ -14,7 +14,7 @@ import ( func TestConsensusClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 7) + registry, keys, _ := epoch.GenRegistry(rng, 7) committee := registry.LatestEpoch().Committee() env := newTestEnv(registry) // Run only a subset of replicas, to enforce timeouts. diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 549a234152..8f98802087 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -92,7 +92,7 @@ func (e *testEnv) Run(ctx context.Context) error { func TestDataClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 2) + registry, keys, _ := epoch.GenRegistry(rng, 2) env := newTestEnv(registry) server := env.AddNode(keys[0]) client := env.AddNode(keys[1]) diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 9be48343c3..e5d53f0d8b 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -35,13 +35,14 @@ import ( const maxInboundFullnodePeers = 10000 type gigaRouterCommon struct { - cfg *GigaRouterCommonConfig - key NodeSecretKey - data *data.State - service *giga.Service - poolIn *giga.Pool[NodePublicKey, rpc.Server[giga.API]] - poolOut *giga.Pool[NodePublicKey, rpc.Client[giga.API]] - app *proxy.Proxy + cfg *GigaRouterCommonConfig + key NodeSecretKey + data *data.State + epochTrio *atomic.Pointer[atypes.EpochTrio] + service *giga.Service + poolIn *giga.Pool[NodePublicKey, rpc.Server[giga.API]] + poolOut *giga.Pool[NodePublicKey, rpc.Client[giga.API]] + app *proxy.Proxy // inboundFullnodeCount tracks live non-committee inbound block-sync // connections. Optimistic Add(1) + compare against cap; over-rejects @@ -519,6 +520,6 @@ func (r *gigaRouterCommon) RunInboundConn(ctx context.Context, hConn *handshaked // None if the caller should handle it locally. Overridden on // *gigaValidatorRouter to short-circuit self-shard sends. func (r *gigaRouterCommon) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := r.epochTrio.Load().Current.Committee().EvmShard(sender) return utils.Some(r.cfg.ValidatorAddrs[shardValidator].EVMRPC) } diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index afd0e1fa3f..ba3e0a0856 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -28,6 +28,7 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gig cfg: cfg, key: key, data: dataState, + epochTrio: dataState.EpochTrio(), service: giga.NewBlockSyncService(dataState), poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 9fe39e6dbf..59b36b0ceb 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -45,6 +45,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV cfg: &cfg.GigaRouterCommonConfig, key: key, data: dataState, + epochTrio: dataState.EpochTrio(), service: giga.NewService(consensusState), poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), @@ -89,7 +90,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { // EvmProxy on the validator returns None when the sender's shard owner is // us (handle locally via mempool, no HTTP round-trip to self). func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := r.epochTrio.Load().Current.Committee().EvmShard(sender) if r.validatorKey == shardValidator { return utils.None[*url.URL]() } From 2f4c37f9990c1ce870e6ff01c0edaafa67313fb8 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 20:49:48 -0700 Subject: [PATCH 02/98] =?UTF-8?q?fix(autobahn):=20reweightForNextEpoch=20i?= =?UTF-8?q?nitializes=20queues=20for=20Current=E2=88=AANext=20lanes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/avail/inner.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 8a08200387..d6cb3ab2d7 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -161,12 +161,12 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, trio types.EpochT return nil, false } -// reweightForNextEpoch initializes queues for any new lanes in nextTrio.Current and +// reweightForNextEpoch initializes queues for any new lanes in nextTrio and // recalculates vote weights across all lanes using the new committee. // Returns true if any block newly reached quorum under the new committee. func (i *inner) reweightForNextEpoch(nextTrio types.EpochTrio) bool { activeLanes := nextTrio.CurrentAndNextLanes() - for lane := range nextTrio.Current.Committee().Lanes().All() { + for lane := range activeLanes { if _, ok := i.blocks[lane]; !ok { i.blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() } From b383ba5a50a454ec23cb710dc7c9371366e68f8c Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 20:52:16 -0700 Subject: [PATCH 03/98] doc(autobahn): explain PushQC epoch window narrowing Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/data/state.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 99a9cc7854..cd7c871af0 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -380,8 +380,9 @@ func (s *State) EpochTrio() *atomic.Pointer[types.EpochTrio] { return &s.epochTr // Even if the qc was already pushed earlier, the blocks are pushed anyway. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { // epochTrio is updated atomically inside the lock at epoch boundaries. - // AdvanceIfNeeded (AppQC path) always fires before any CommitQC for the - // same epoch, so the epoch for this QC is guaranteed present. + // EpochForRoad searches the Prev/Current/Next window. A QC more than one + // epoch behind Current has its blocks below the prune cursor and would be + // a no-op even if accepted, so erroring out here is correct. ep, err := s.epochTrio.Load().EpochForRoad(qc.QC().Proposal().Index()) if err != nil { return err From ccb2bb43ff184f4c6a26e0725939c3097e12392f Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 21:02:27 -0700 Subject: [PATCH 04/98] fix(autobahn): seal registry after fullnode data layer init Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga_router_fullnode.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index ba3e0a0856..c27080a6f4 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -22,6 +22,7 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gig if err != nil { return nil, err } + dataState.Registry().SealSeeding() logger.Info("GigaRouter initialized (fullnode)", "validators", len(cfg.ValidatorAddrs), "dial_interval", cfg.DialInterval, "inbound_fullnode_cap", cfg.MaxInboundFullnodePeers) return &gigaFullnodeRouter{ gigaRouterCommon: &gigaRouterCommon{ From 8e8d4b3749b991198f9ea87d7e445c33d2d13ddb Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 21:04:04 -0700 Subject: [PATCH 05/98] fix(autobahn): retain Prev-epoch lanes until boundary QC is collected Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/autobahn/types/epoch_trio.go | 15 +++++++++++++++ sei-tendermint/internal/autobahn/avail/inner.go | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/autobahn/types/epoch_trio.go b/sei-tendermint/autobahn/types/epoch_trio.go index a07cba8805..fc7668b8f0 100644 --- a/sei-tendermint/autobahn/types/epoch_trio.go +++ b/sei-tendermint/autobahn/types/epoch_trio.go @@ -40,6 +40,21 @@ func (w EpochTrio) CurrentAndNextLanes() map[LaneID]struct{} { return lanes } +// AllLanes returns the union of lanes across all three epochs (Prev, Current, Next). +// Used when decommissioning lanes: Prev-epoch lanes must be retained until any +// boundary QC that spans the epoch transition has been fully collected. +func (w EpochTrio) AllLanes() map[LaneID]struct{} { + lanes := make(map[LaneID]struct{}) + for _, ep := range w.all() { + if ep != nil { + for lane := range ep.Committee().Lanes().All() { + lanes[lane] = struct{}{} + } + } + } + return lanes +} + // VerifyInWindow calls fn against Current and Next only, skipping Prev. // Use for votes and blocks, which must belong to the current or upcoming epoch. func (w EpochTrio) VerifyInWindow(fn func(*Committee) error) (*Epoch, error) { diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index d6cb3ab2d7..7500f4df97 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -180,8 +180,13 @@ func (i *inner) reweightForNextEpoch(nextTrio types.EpochTrio) bool { i.persistedBlockStart[lane] = 0 } } + // Retain Prev-epoch lanes in the deletion guard: fullCommitQC may still be + // collecting headers from the boundary QC that triggered this reweight, and + // it accesses lane queues by epoch N's committee. Prev lanes are cleaned up + // naturally on the next epoch advance when they fall outside AllLanes(). + retainLanes := nextTrio.AllLanes() for lane := range i.blocks { - if _, ok := activeLanes[lane]; !ok { + if _, ok := retainLanes[lane]; !ok { delete(i.blocks, lane) delete(i.votes, lane) delete(i.nextBlockToPersist, lane) From 9fca133da062b658861c80de9a5889b4c359ce61 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 21:04:45 -0700 Subject: [PATCH 06/98] test(autobahn): verify Prev-epoch lanes retained across reweightForNextEpoch Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/inner_test.go | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index ef6247a26a..86bb9685a1 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -883,3 +883,28 @@ func TestReweightForNextEpoch_EmptyQueuesReturnsFalse(t *testing.T) { // No votes in any queue; reweight should not signal quorum. require.False(t, i.reweightForNextEpoch(trio)) } + +func TestReweightForNextEpoch_RetainsPrevEpochLanes(t *testing.T) { + rng := utils.TestRng() + registry, _, _ := epoch.GenRegistry(rng, 4) + + // trio0: Prev=nil, Current=epoch0, Next=epoch1 + trio0 := utils.OrPanic1(registry.TrioAt(0)) + i, err := newInner(trio0, utils.None[*loadedAvailState]()) + require.NoError(t, err) + + // Collect a lane from epoch0 (will become Prev after advance). + var epoch0Lane types.LaneID + for l := range trio0.Current.Committee().Lanes().All() { + epoch0Lane = l + break + } + require.Contains(t, i.blocks, epoch0Lane, "epoch0 lane missing before reweight") + + // trio1: Prev=epoch0, Current=epoch1, Next=epoch2 + trio1 := utils.OrPanic1(registry.TrioAt(epoch.EpochLength)) + i.reweightForNextEpoch(trio1) + + // Epoch0 lane is now in Prev — must be retained for boundary QC collection. + require.Contains(t, i.blocks, epoch0Lane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") +} From 428fe239cff2f48e609eb4e2f74507eb1fc00f52 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 21:10:12 -0700 Subject: [PATCH 07/98] =?UTF-8?q?fix(autobahn):=20correct=20midpoint=20gat?= =?UTF-8?q?e=20N+2=E2=86=92N+1=20and=20data=20restart=20epoch=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Midpoint gate: AppQC is downstream of CommitQC so N+2 is never seeded at midpoint of epoch N; deadlocks the chain at road MidPoint(0). Gate on N+1 for now; tighten to N+2 when AppQC carries committee from end(N). - data.NewState restart: use lastQC.Index()+1 to mirror live PushQC boundary advance; without +1 a crash at the last road of epoch N regresses epochTrio.Current and the data goroutine terminates at road 2*EpochLength. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/consensus/inner.go | 19 +++++++++---------- .../internal/autobahn/data/state.go | 6 +++++- .../internal/autobahn/epoch/registry.go | 13 +++++++------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index f030fa3cd1..87b5c0840b 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -209,18 +209,17 @@ func (s *State) pushProposal(ctx context.Context, proposal *types.FullProposal) if i.View() != proposal.View() || i.TimeoutVote.IsPresent() || i.PrepareVote.IsPresent() { return nil } - // At the midpoint of epoch N, gate on epoch N+2 being seeded in the registry. - // C_{e+2} = Committee(end(e)): the execution layer derives the N+2 committee - // from the last block of epoch N and delivers it via AppQC from epoch N+1 roads. - // The AppQC pipeline runs ~one epoch ahead of CommitQC, so by the midpoint of - // epoch N, AdvanceIfNeeded has already processed AppQC from epoch N+1 and seeded - // N+2. This gate is intentional back-pressure: if AppQC is lagging, consensus - // stalls here rather than entering epoch N+1 without a known N+2 committee. + // At the midpoint of epoch N, gate on epoch N+1 being seeded in the registry. + // The first AppQC in epoch N seeds N+1 via AdvanceIfNeeded, so this is + // trivially satisfied today (all epochs share the genesis committee). + // TODO: tighten to N+2 once AppQC carries the committee derived from the last + // block of epoch N — at that point N+2 won't be seeded until execution + // finalizes end(N), and this gate becomes meaningful back-pressure. if proposal.Proposal().Msg().Index() == i.epoch.RoadRange().MidPoint() { - if _, err := i.registry.EpochAt(types.RoadIndex(i.epoch.EpochIndex()+2) * epoch.EpochLength); err != nil { - logger.Error("refusing PrepareVote: epoch N+2 not yet seeded at epoch midpoint", + if _, err := i.registry.EpochAt(types.RoadIndex(i.epoch.EpochIndex()+1) * epoch.EpochLength); err != nil { + logger.Error("refusing PrepareVote: epoch N+1 not yet seeded at epoch midpoint", "road", proposal.Proposal().Msg().Index(), - "missing", i.epoch.EpochIndex()+2) + "missing", i.epoch.EpochIndex()+1) return nil } } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index cd7c871af0..35c7762043 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -351,7 +351,11 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { initRoad := types.RoadIndex(0) if inner.nextQC > 0 { if lastQC := inner.qcs[inner.nextQC-1]; lastQC != nil { - initRoad = lastQC.QC().Proposal().Index() + // Use Index+1 to mirror the live PushQC boundary-advance: when the last + // committed QC is the final road of epoch N, TrioAt(N.Last) returns + // Current=ep(N), but TrioAt(N.Last+1) correctly returns Current=ep(N+1), + // matching the epochTrio the live path stored before the crash. + initRoad = lastQC.QC().Proposal().Index() + 1 } } initTrio, err := cfg.Registry.TrioAt(initRoad) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index fab4ce4b54..4361640c7b 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -105,12 +105,13 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type // AdvanceIfNeeded ensures the epoch following appQC's epoch exists in the registry. // Creates the next epoch with the genesis committee if absent. // -// Seeding model: an AppQC at road R (epoch N) seeds epoch N+1. The execution -// pipeline runs ~one epoch ahead of CommitQC, so by the time CommitQC reaches -// the midpoint of epoch N, AppQC has already delivered roads from epoch N+1, -// causing AdvanceIfNeeded to seed epoch N+2. Consensus's midpoint liveness gate -// (consensus/inner.go) checks that epoch N+2 is present and stalls if AppQC has -// not yet caught up — this is intentional back-pressure, not a bug. +// Seeding model: an AppQC at road R (epoch N) seeds epoch N+1. Execution is +// downstream of CommitQC — AppQC for road R is produced only after CommitQC for +// road R is finalized — so AppQC never runs ahead of consensus. +// Consensus's midpoint liveness gate (consensus/inner.go) checks that epoch N+1 +// is present; since the first AppQC in epoch N seeds N+1 immediately, the gate +// is trivially satisfied today. The gate will enforce a stricter N+2 check once +// AppQC carries the committee derived from the last block of epoch N (future step). // // EpochLength is chosen large enough that this is always called before any // CommitQC for epoch N+1 reaches data.State — guaranteeing data can look up From 91801ca91c949ca72485b5c7c0fc3fdabe4b709e Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 21:27:25 -0700 Subject: [PATCH 08/98] fix(autobahn): seed N+2 in AdvanceIfNeeded and restore midpoint gate to N+2 AdvanceIfNeeded now seeds both N+1 and N+2 from each epoch-N AppQC. This guarantees that by MidPoint(N), epoch N+2 is present in the registry, so TrioAt(N.Last+1) at the epoch boundary always finds N+2 as Next. The midpoint gate is restored to check N+2 (was temporarily weakened to N+1 when AdvanceIfNeeded only seeded N+1). With N+2 now seeded by the first AppQC of epoch N, the gate is satisfied immediately and provides the back-pressure needed before any epoch-boundary CommitQC arrives. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/consensus/inner.go | 17 +++++++------ .../internal/autobahn/epoch/registry.go | 24 +++++++++---------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 87b5c0840b..21b6eb2c7e 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -209,17 +209,16 @@ func (s *State) pushProposal(ctx context.Context, proposal *types.FullProposal) if i.View() != proposal.View() || i.TimeoutVote.IsPresent() || i.PrepareVote.IsPresent() { return nil } - // At the midpoint of epoch N, gate on epoch N+1 being seeded in the registry. - // The first AppQC in epoch N seeds N+1 via AdvanceIfNeeded, so this is - // trivially satisfied today (all epochs share the genesis committee). - // TODO: tighten to N+2 once AppQC carries the committee derived from the last - // block of epoch N — at that point N+2 won't be seeded until execution - // finalizes end(N), and this gate becomes meaningful back-pressure. + // At the midpoint of epoch N, gate on epoch N+2 being seeded in the registry. + // AdvanceIfNeeded seeds both N+1 and N+2 from each epoch-N AppQC, so the + // first AppQC of epoch N satisfies this gate immediately. The gate ensures + // that TrioAt(N.Last+1) — called at the epoch boundary in data and avail — + // always finds N+2 as Next without erroring. if proposal.Proposal().Msg().Index() == i.epoch.RoadRange().MidPoint() { - if _, err := i.registry.EpochAt(types.RoadIndex(i.epoch.EpochIndex()+1) * epoch.EpochLength); err != nil { - logger.Error("refusing PrepareVote: epoch N+1 not yet seeded at epoch midpoint", + if _, err := i.registry.EpochAt(types.RoadIndex(i.epoch.EpochIndex()+2) * epoch.EpochLength); err != nil { + logger.Error("refusing PrepareVote: epoch N+2 not yet seeded at epoch midpoint", "road", proposal.Proposal().Msg().Index(), - "missing", i.epoch.EpochIndex()+1) + "missing", i.epoch.EpochIndex()+2) return nil } } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 4361640c7b..a788baa825 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -102,20 +102,17 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type return epoch, nil } -// AdvanceIfNeeded ensures the epoch following appQC's epoch exists in the registry. -// Creates the next epoch with the genesis committee if absent. +// AdvanceIfNeeded ensures epochs N+1 and N+2 exist for an AppQC in epoch N. +// Creates missing epochs with the genesis committee placeholder. // -// Seeding model: an AppQC at road R (epoch N) seeds epoch N+1. Execution is +// Seeding model: AppQC at road R (epoch N) seeds both N+1 and N+2. Execution is // downstream of CommitQC — AppQC for road R is produced only after CommitQC for // road R is finalized — so AppQC never runs ahead of consensus. -// Consensus's midpoint liveness gate (consensus/inner.go) checks that epoch N+1 -// is present; since the first AppQC in epoch N seeds N+1 immediately, the gate -// is trivially satisfied today. The gate will enforce a stricter N+2 check once -// AppQC carries the committee derived from the last block of epoch N (future step). // -// EpochLength is chosen large enough that this is always called before any -// CommitQC for epoch N+1 reaches data.State — guaranteeing data can look up -// the new epoch from the window without falling back to EpochAt. +// Seeding two epochs ahead guarantees that by MidPoint(N), both N+1 and N+2 are +// present. The midpoint liveness gate (consensus/inner.go) checks for N+2, +// ensuring the epoch boundary TrioAt(N.Last+1) — which needs N+2 as Next — never +// fails in the live path. // // TODO: real committee rotation — the next epoch's committee must be derived from // stake changes recorded in the blocks up to appQC. This requires the execution @@ -123,10 +120,11 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type // wired, all epochs are created with the genesis committee as a placeholder. func (r *Registry) AdvanceIfNeeded(appQC *types.AppQC) { currentIdx := types.EpochIndex(appQC.Proposal().RoadIndex() / EpochLength) - nextIdx := currentIdx + 1 for s := range r.state.Lock() { - if _, ok := s.m[nextIdx]; !ok { - _, _ = r.makeEpoch(s, nextIdx) //nolint:errcheck // genesis always present + for _, idx := range []types.EpochIndex{currentIdx + 1, currentIdx + 2} { + if _, ok := s.m[idx]; !ok { + _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present + } } if currentIdx > s.latest { s.latest = currentIdx From c953b49cd241ef6c32cb5f4fc8fa61007ee9d96a Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 21:59:53 -0700 Subject: [PATCH 09/98] fix(autobahn): execution-driven epoch seeding via AdvanceIfNeeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epoch seeding is now driven by block execution rather than AppQC: - Registry.AdvanceIfNeeded(roadIndex) seeds epoch N+2 when a block in epoch N is executed. Execution in epoch N-1 seeds N+1 (same rule applied one epoch earlier), giving TrioAt the Next it needs at every epoch boundary. - executeBlock calls AdvanceIfNeeded via FinalAppState.RoadIndex() on both the validator and full-node paths — single seeding source. - Removed AdvanceIfNeeded calls from avail.State (PushVote, PushAppQC); AppQC no longer drives registry seeding. - giga_router_fullnode.go retains SealSeeding: full nodes now advance the registry through execution, so sealing is safe. - Midpoint gate (consensus/inner.go) still checks EpochAt(N+2), which is guaranteed present after the first executed block of epoch N. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/state.go | 10 +---- .../internal/autobahn/epoch/registry.go | 40 +++++++------------ .../internal/autobahn/epoch/registry_test.go | 37 ++++++++--------- .../internal/p2p/giga_router_common.go | 7 ++++ 4 files changed, 40 insertions(+), 54 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 5a727acf6e..1756684ec7 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -306,8 +306,8 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return nil } // At an epoch boundary, initialize lanes for the next epoch. - // The next epoch must already be registered: AdvanceIfNeeded pre-generates - // it during epoch N, well before any CommitQC at the boundary. A miss here + // N+2 is seeded by executeBlock (AdvanceIfNeeded) on every block in epoch N, + // so it is present well before the boundary CommitQC arrives. A miss here // is a registry bug — fail loudly rather than continuing with stale state. if idx == s.epochTrio.Load().Current.RoadRange().Last { nextTrio, err := s.data.Registry().TrioAt(idx + 1) @@ -372,7 +372,6 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] } if updated { ctrl.Updated() - s.data.Registry().AdvanceIfNeeded(appQC) } } return nil @@ -408,20 +407,15 @@ func (s *State) PushAppQC(appQC *types.AppQC, commitQC *types.CommitQC) error { if !commitQC.GlobalRange().Has(appQC.Proposal().GlobalNumber()) { return fmt.Errorf("appQC GlobalNumber not in commitQC range") } - var accepted bool for inner, ctrl := range s.inner.Lock() { updated, err := inner.prune(appQC, commitQC) if err != nil { return err } if updated { - accepted = true ctrl.Updated() } } - if accepted { - s.data.Registry().AdvanceIfNeeded(appQC) - } return nil } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index a788baa825..71d6651354 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -41,7 +41,7 @@ func NewRegistry( // SealSeeding marks the end of the initialization seeding phase. After this // call, EpochAt will no longer auto-generate missing epochs; new epochs can -// only be created via AdvanceIfNeeded (driven by AppQC). +// only be created via AdvanceIfNeeded (driven by block execution). // // Must be called once, after all layers (data, avail, consensus) have finished // their NewState initialization. @@ -89,7 +89,7 @@ func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { // makeEpoch constructs a new epoch at epochIdx using the genesis committee and // inserts it into s. Caller must hold the write lock. -// Note: does NOT advance s.latest; that only happens on real epoch activation. +// Note: does NOT advance s.latest. func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*types.Epoch, error) { genesis, ok := s.m[0] if !ok { @@ -102,32 +102,22 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type return epoch, nil } -// AdvanceIfNeeded ensures epochs N+1 and N+2 exist for an AppQC in epoch N. -// Creates missing epochs with the genesis committee placeholder. +// AdvanceIfNeeded seeds epoch N+2 when a block in epoch N is executed. +// Called by executeBlock (giga_router_common.go) on both validator and full-node paths. // -// Seeding model: AppQC at road R (epoch N) seeds both N+1 and N+2. Execution is -// downstream of CommitQC — AppQC for road R is produced only after CommitQC for -// road R is finalized — so AppQC never runs ahead of consensus. +// Seeding model: execution of any block in epoch N seeds epoch N+2 as a +// placeholder (genesis committee). Epoch N+1 is seeded by executing epoch N-1 +// blocks (same rule applied one epoch earlier). The midpoint liveness gate +// (consensus/inner.go) checks that N+2 is present before voting at MidPoint(N), +// ensuring TrioAt(N.Last+1) — which needs N+2 as Next — always succeeds. // -// Seeding two epochs ahead guarantees that by MidPoint(N), both N+1 and N+2 are -// present. The midpoint liveness gate (consensus/inner.go) checks for N+2, -// ensuring the epoch boundary TrioAt(N.Last+1) — which needs N+2 as Next — never -// fails in the live path. -// -// TODO: real committee rotation — the next epoch's committee must be derived from -// stake changes recorded in the blocks up to appQC. This requires the execution -// layer to pass in the new committee alongside the AppQC. Until that bridge is -// wired, all epochs are created with the genesis committee as a placeholder. -func (r *Registry) AdvanceIfNeeded(appQC *types.AppQC) { - currentIdx := types.EpochIndex(appQC.Proposal().RoadIndex() / EpochLength) +// TODO: real committee rotation — pass the derived committee for N+2 here once +// the execution layer computes it from the last block of epoch N. +func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { + nextNextIdx := types.EpochIndex(roadIndex/EpochLength) + 2 for s := range r.state.Lock() { - for _, idx := range []types.EpochIndex{currentIdx + 1, currentIdx + 2} { - if _, ok := s.m[idx]; !ok { - _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present - } - } - if currentIdx > s.latest { - s.latest = currentIdx + if _, ok := s.m[nextNextIdx]; !ok { + _, _ = r.makeEpoch(s, nextNextIdx) //nolint:errcheck // genesis always present } } } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 50c3e81c18..f2f62a369f 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -65,19 +65,15 @@ func TestEpochAt_ErrorIfNotRegistered(t *testing.T) { func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { r, _ := makeRegistry(t) - rng := utils.TestRng() - key := types.GenSecretKey(rng) - proposal := types.NewAppProposal(0, EpochLength-1, types.AppHash{}, types.EpochIndex(0)) - appQC := types.NewAppQC([]*types.Signed[*types.AppVote]{ - types.Sign(key, types.NewAppVote(proposal)), - }) - r.AdvanceIfNeeded(appQC) - ep, err := r.EpochAt(EpochLength) + r.SealSeeding() + // Executing any block in epoch 0 seeds epoch 2 (N+2). + r.AdvanceIfNeeded(EpochLength - 1) + ep, err := r.EpochAt(2 * EpochLength) if err != nil { - t.Fatalf("EpochAt(EpochLength) after AdvanceIfNeeded: %v", err) + t.Fatalf("EpochAt(2*EpochLength) after AdvanceIfNeeded: %v", err) } - if ep.EpochIndex() != 1 { - t.Fatalf("EpochAt(EpochLength).EpochIndex() = %d, want 1", ep.EpochIndex()) + if ep.EpochIndex() != 2 { + t.Fatalf("EpochAt(2*EpochLength).EpochIndex() = %d, want 2", ep.EpochIndex()) } } @@ -103,17 +99,16 @@ func TestSeeding_DoesNotOverwriteExisting(t *testing.T) { } } -func TestAdvanceIfNeeded_AdvancesLatest(t *testing.T) { +func TestAdvanceIfNeeded_SeedsNextNext(t *testing.T) { r, _ := makeRegistry(t) - rng := utils.TestRng() - key := types.GenSecretKey(rng) - proposal := types.NewAppProposal(0, EpochLength-1, types.AppHash{}, types.EpochIndex(0)) - appQC := types.NewAppQC([]*types.Signed[*types.AppVote]{ - types.Sign(key, types.NewAppVote(proposal)), - }) - r.AdvanceIfNeeded(appQC) - if latest := r.LatestEpoch().EpochIndex(); latest != 0 { - t.Fatalf("LatestEpoch().EpochIndex() = %d after AdvanceIfNeeded in epoch 0, want 0", latest) + r.SealSeeding() + // Epoch 0 execution seeds epoch 2, not epoch 1. + r.AdvanceIfNeeded(0) + if _, err := r.EpochAt(2 * EpochLength); err != nil { + t.Fatalf("EpochAt(epoch 2) after AdvanceIfNeeded(epoch 0): %v", err) + } + if _, err := r.EpochAt(EpochLength); err == nil { + t.Fatal("EpochAt(epoch 1) should not be seeded by AdvanceIfNeeded(epoch 0) after sealing") } } diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index e5d53f0d8b..72442e2b11 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -268,6 +268,13 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo if err := r.data.PushAppHash(ctx, b.GlobalNumber, resp.AppHash); err != nil { return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } + // Seed epoch N+2 in the registry. Both validators and full nodes execute + // blocks, so this is the single path that advances epoch seeding. + // When real committee rotation is implemented, the last block of epoch N will + // pass the derived committee for N+2 here instead of using the placeholder. + if appState, ok := b.FinalAppState.Get(); ok { + r.data.Registry().AdvanceIfNeeded(appState.RoadIndex()) + } return commitResp, nil } From 1e5cfd1659c45827beddc7c455e9b22123c9b8d2 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 22:37:00 -0700 Subject: [PATCH 10/98] fix(autobahn): preserve block header across epoch reweight, align epoch derivation - avail: introduce laneVoteSet with header field (set on first vote, never cleared) so headers() can recover block headers after committee rotation empties the votes slice; add laneVoteSet.reweight() method that resets only weight and votes - avail: headers() reads entry.header instead of entry.votes[0].Msg().Header() - consensus: derive next epoch in pushCommitQC via EpochAt(road+1) instead of trusting qc.Proposal().EpochIndex(), consistent with data/avail layers - consensus: document midpoint gate as best-effort for actively-voting nodes - consensus: revert spurious epoch lookup in pushCommitQC (QCs arrive in order so i.epoch is always current) Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/block_votes.go | 25 ++++++-- .../autobahn/avail/block_votes_test.go | 61 +++++++++++++++++++ .../internal/autobahn/avail/state.go | 2 +- .../internal/autobahn/consensus/inner.go | 23 ++++--- 4 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 sei-tendermint/internal/autobahn/avail/block_votes_test.go diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index ff66bdbc56..ce4195c313 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -4,15 +4,29 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" ) +// laneVoteSet tracks weighted votes for a single block hash. +// header is set on the first vote and never cleared by reweight, so callers +// can always recover the block header even after committee rotation empties votes. +type laneVoteSet struct { + weight uint64 + votes []*types.Signed[*types.LaneVote] + header *types.BlockHeader +} + +func (s *laneVoteSet) reweight() { + s.weight = 0 + s.votes = s.votes[:0] +} + type blockVotes struct { byKey map[types.PublicKey]*types.Signed[*types.LaneVote] - byHash map[types.BlockHeaderHash]*voteSet[*types.Signed[*types.LaneVote]] + byHash map[types.BlockHeaderHash]*laneVoteSet } func newBlockVotes() blockVotes { return blockVotes{ byKey: map[types.PublicKey]*types.Signed[*types.LaneVote]{}, - byHash: map[types.BlockHeaderHash]*voteSet[*types.Signed[*types.LaneVote]]{}, + byHash: map[types.BlockHeaderHash]*laneVoteSet{}, } } @@ -29,7 +43,7 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot bv.byKey[k] = vote byHash, ok := bv.byHash[h] if !ok { - byHash = &voteSet[*types.Signed[*types.LaneVote]]{} + byHash = &laneVoteSet{header: vote.Msg().Header()} bv.byHash[h] = byHash } if byHash.weight >= c.LaneQuorum() { @@ -47,11 +61,12 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot // newEpoch's committee. Called when the epoch advances so that votes from // validators who were in the next epoch are now counted. Returns true if any // block hash newly reached quorum under the new committee. +// Each laneVoteSet.reweight() clears weight and votes but preserves header, +// so headers() can always recover block headers even after committee rotation. func (bv blockVotes) reweight(newEpoch *types.Epoch) bool { c := newEpoch.Committee() for _, set := range bv.byHash { - set.weight = 0 - set.votes = set.votes[:0] + set.reweight() } quorumReached := false for k, vote := range bv.byKey { diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go new file mode 100644 index 0000000000..04b00ac42e --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -0,0 +1,61 @@ +package avail + +import ( + "testing" + "time" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/stretchr/testify/require" +) + +// TestReweightPreservesHeader verifies that after reweight empties the votes +// slice (because committee rotation removed all voters for a block hash), the +// block header stored at first-vote time is still accessible. This matters +// because headers() reads entry.header to build the parent-hash chain for +// fullCommitQC, and that call arrives after reweightForNextEpoch has run. +func TestReweightPreservesHeader(t *testing.T) { + rng := utils.TestRng() + + // Epoch 0: key A only. Epoch 1: key B only (disjoint committees). + // LaneQuorum for a 1-validator committee is 1, so A's vote alone reaches quorum. + // After reweight to ep1, A's vote is cleared from votes — but header must survive. + keyA := types.GenSecretKey(rng) + keyB := types.GenSecretKey(rng) + + makeEpoch := func(idx types.EpochIndex, weights map[types.PublicKey]uint64) *types.Epoch { + c := utils.OrPanic1(types.NewCommittee(weights)) + first := types.RoadIndex(uint64(idx) * 108_000) + rr := types.RoadRange{First: first, Last: first + 107_999} + return types.NewEpoch(idx, rr, time.Time{}, c, 0) + } + + ep0 := makeEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) + ep1 := makeEpoch(1, map[types.PublicKey]uint64{keyB.Public(): 1}) + + lane := keyA.Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + header := block.Header() + + bv := newBlockVotes() + + // A's vote reaches quorum immediately (LaneQuorum=1). + lqc, ok := bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))) + require.NotNil(t, lqc, "quorum should be reached on A's vote") + require.True(t, ok) + + h := header.Hash() + entry := bv.byHash[h] + require.NotNil(t, entry.header, "header should be set after pushVote") + require.Equal(t, header, entry.header) + require.Len(t, entry.votes, 1) + + // Reweight to epoch 1: keyA and keyB have weight 0, so votes is emptied. + bv.reweight(ep1) + + entry = bv.byHash[h] + require.Empty(t, entry.votes, "votes should be empty after reweight removes old-committee voters") + require.Equal(t, uint64(0), entry.weight) + require.NotNil(t, entry.header, "header must survive reweight") + require.Equal(t, header, entry.header, "header content must be unchanged after reweight") +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 1756684ec7..cd9011659f 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -562,7 +562,7 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc } // Check if we have the header. if entry, ok := q.q[n].byHash[want]; ok { - h := entry.votes[0].Msg().Header() + h := entry.header want = h.ParentHash() headers[len(headers)-i-1] = h break diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 21b6eb2c7e..d88dd15bb0 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -145,16 +145,11 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } - // CommitQC advances to new index; look up the epoch for the next road. - nextEpIdx := qc.Proposal().EpochIndex() - if qc.Proposal().Index() == i.epoch.RoadRange().Last { - nextEpIdx++ - } - nextEp, err := i.registry.EpochAt(types.RoadIndex(nextEpIdx) * epoch.EpochLength) + nextEp, err := i.registry.EpochAt(qc.Proposal().Index() + 1) if err != nil { logger.Error("next epoch not in registry at CommitQC boundary", - "epochIndex", nextEpIdx, "road", qc.Proposal().Index()) - return fmt.Errorf("epoch %d not in registry", nextEpIdx) + "road", qc.Proposal().Index()+1) + return fmt.Errorf("EpochAt(%d): %w", qc.Proposal().Index()+1, err) } iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, registry: i.registry, epoch: nextEp}) } @@ -210,10 +205,14 @@ func (s *State) pushProposal(ctx context.Context, proposal *types.FullProposal) return nil } // At the midpoint of epoch N, gate on epoch N+2 being seeded in the registry. - // AdvanceIfNeeded seeds both N+1 and N+2 from each epoch-N AppQC, so the - // first AppQC of epoch N satisfies this gate immediately. The gate ensures - // that TrioAt(N.Last+1) — called at the epoch boundary in data and avail — - // always finds N+2 as Next without erroring. + // AdvanceIfNeeded (called by executeBlock) seeds N+2 on every executed block + // in epoch N, so the gate is satisfied as soon as any block in epoch N has + // been executed. This ensures TrioAt(N.Last+1) — called at the epoch boundary + // in data and avail — always finds N+2 as Next without erroring. + // Note: this is a best-effort gate for actively-voting nodes. A node that + // catches up past the midpoint via CommitQC sync (without processing the + // midpoint proposal) bypasses the check; that is acceptable because such a + // node has already executed enough blocks to seed N+2 anyway. if proposal.Proposal().Msg().Index() == i.epoch.RoadRange().MidPoint() { if _, err := i.registry.EpochAt(types.RoadIndex(i.epoch.EpochIndex()+2) * epoch.EpochLength); err != nil { logger.Error("refusing PrepareVote: epoch N+2 not yet seeded at epoch midpoint", From 32b3decc111610e276d58987b68f665f36b0e4da Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 22:49:13 -0700 Subject: [PATCH 11/98] fix(autobahn): RLock fast-path for EpochAt/AdvanceIfNeeded, clarify PushQC invariant - epoch: EpochAt and AdvanceIfNeeded now check under RLock first; only escalate to write lock when the epoch is missing (seeding path / first block of a new epoch). Avoids write-lock serialization on every block execution and every epoch lookup in the common post-seal case. - data: add comment to PushQC clarifying that blocks are verified against the incoming QC's epoch (all provided blocks belong to that epoch), which differs from PushBlock's per-position committee resolution. Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/data/state.go | 4 ++++ .../internal/autobahn/epoch/registry.go | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 35c7762043..28a3cf3d36 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -412,6 +412,10 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("qc.Verify(): %w", err) } } + // blocks is the subset of blocks finalized by this QC, so they all belong + // to the same epoch as the QC. Using ep here is intentional and asymmetric + // with PushBlock, which resolves the committee from the stored QC at each + // block's position rather than from the incoming QC. byHash := map[types.BlockHeaderHash]*types.Block{} committee := ep.Committee() for _, b := range blocks { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 71d6651354..0fbc54a604 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -71,13 +71,20 @@ func (r *Registry) FirstBlock() types.GlobalBlockNumber { // if the epoch has not been registered via AdvanceIfNeeded. func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { epochIdx := types.EpochIndex(roadIndex / EpochLength) - for s := range r.state.Lock() { + // Fast path: read lock covers the common post-seal case. + for s := range r.state.RLock() { if ep, ok := s.m[epochIdx]; ok { return ep, nil } if !s.seeding { return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) } + } + // Slow path: seeding phase with a missing epoch — create under write lock. + for s := range r.state.Lock() { + if ep, ok := s.m[epochIdx]; ok { + return ep, nil // re-check after acquiring write lock + } ep, _ := r.makeEpoch(s, epochIdx) //nolint:errcheck // genesis always present if ep == nil { return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) @@ -115,6 +122,12 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type // the execution layer computes it from the last block of epoch N. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { nextNextIdx := types.EpochIndex(roadIndex/EpochLength) + 2 + // Fast path: epoch already seeded (common after the first block of the epoch). + for s := range r.state.RLock() { + if _, ok := s.m[nextNextIdx]; ok { + return + } + } for s := range r.state.Lock() { if _, ok := s.m[nextNextIdx]; !ok { _, _ = r.makeEpoch(s, nextNextIdx) //nolint:errcheck // genesis always present From 25959368b54dacb74665012813ed3c0260f3ac20 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 12 Jul 2026 22:51:50 -0700 Subject: [PATCH 12/98] fix(autobahn): exclude zero-weight signers from LaneQC votes in pushVote A next-epoch-only validator whose lane vote is accepted via VerifyInWindow (Current rejects, Next accepts) reaches pushVote with the Current committee. Previously, weight was correctly unchanged (c.Weight(k)==0) but the vote was still appended to byHash.votes, producing a LaneQC whose sigs include a key not in Current. Peers verify all sigs against Current and reject the proposal. Fix: guard both the weight increment and the byHash.votes append on c.Weight(k) != 0, mirroring the existing guard in reweight. The vote still lands in byKey so reweightForNextEpoch picks it up at the epoch boundary. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/block_votes.go | 10 +++-- .../autobahn/avail/block_votes_test.go | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index ce4195c313..a91bedf42b 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -49,10 +49,12 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot if byHash.weight >= c.LaneQuorum() { return nil, false } - byHash.weight += c.Weight(k) - byHash.votes = append(byHash.votes, vote) - if byHash.weight >= c.LaneQuorum() { - return types.NewLaneQC(byHash.votes), true + if w := c.Weight(k); w != 0 { + byHash.weight += w + byHash.votes = append(byHash.votes, vote) + if byHash.weight >= c.LaneQuorum() { + return types.NewLaneQC(byHash.votes), true + } } return nil, false } diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 04b00ac42e..3cbe4a71a6 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -9,6 +9,50 @@ import ( "github.com/stretchr/testify/require" ) +// TestPushVote_ZeroWeightSignerExcludedFromQC verifies that a signer with +// zero weight in the current epoch (e.g. a next-epoch-only validator whose +// vote was accepted via VerifyInWindow) is stored in byKey but does NOT +// appear in byHash.votes or the resulting LaneQC. Including such a signature +// would cause laneQC.Verify to fail on peers when they check all sigs against +// the current committee. +func TestPushVote_ZeroWeightSignerExcludedFromQC(t *testing.T) { + rng := utils.TestRng() + + keyA := types.GenSecretKey(rng) // in current epoch + keyE := types.GenSecretKey(rng) // next-epoch-only (weight=0 in current) + + makeEpoch := func(idx types.EpochIndex, weights map[types.PublicKey]uint64) *types.Epoch { + c := utils.OrPanic1(types.NewCommittee(weights)) + first := types.RoadIndex(uint64(idx) * 108_000) + rr := types.RoadRange{First: first, Last: first + 107_999} + return types.NewEpoch(idx, rr, time.Time{}, c, 0) + } + + ep0 := makeEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) + + lane := keyA.Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + header := block.Header() + + bv := newBlockVotes() + + // E votes first; weight=0 in ep0 so must not appear in byHash.votes. + lqc, ok := bv.pushVote(ep0, types.Sign(keyE, types.NewLaneVote(header))) + require.Nil(t, lqc) + require.False(t, ok) + require.Contains(t, bv.byKey, keyE.Public(), "E's vote must be stored in byKey for future reweight") + entry := bv.byHash[header.Hash()] + require.Empty(t, entry.votes, "E's zero-weight vote must not appear in byHash.votes") + + // A votes and reaches quorum; the resulting LaneQC must contain only A. + lqc, ok = bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))) + require.NotNil(t, lqc) + require.True(t, ok) + entry = bv.byHash[header.Hash()] + require.Len(t, entry.votes, 1, "only A's vote should be in byHash.votes") + require.Equal(t, keyA.Public(), entry.votes[0].Key(), "the sole vote must be A's") +} + // TestReweightPreservesHeader verifies that after reweight empties the votes // slice (because committee rotation removed all voters for a block hash), the // block header stored at first-vote time is still accessible. This matters From 9bdcedc40e47b6c198a31df507a6969b4f13973c Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 07:49:04 -0700 Subject: [PATCH 13/98] fix(autobahn): use Option for EpochTrio.Prev and rename laneVoteSet.reset Address review feedback: represent optional Prev with utils.Option, keep Next required (TrioAt errors if missing), and rename the vote-set clear helper to reset since it does not reweight. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_trio.go | 37 ++++++++++--------- .../autobahn/types/epoch_trio_test.go | 26 ++++--------- .../internal/autobahn/avail/block_votes.go | 8 ++-- .../internal/autobahn/epoch/registry.go | 8 ++-- .../internal/autobahn/epoch/registry_test.go | 9 +++-- 5 files changed, 42 insertions(+), 46 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_trio.go b/sei-tendermint/autobahn/types/epoch_trio.go index fc7668b8f0..ec34ded102 100644 --- a/sei-tendermint/autobahn/types/epoch_trio.go +++ b/sei-tendermint/autobahn/types/epoch_trio.go @@ -1,26 +1,31 @@ package types -import "fmt" +import ( + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) // EpochTrio is a view of up to three consecutive epochs centered on Current. -// Prev and Next may be nil. Updated only when an AppQC advances into a new epoch. +// Current and Next are always present; Prev may be absent (epoch 0). +// Updated only when an AppQC advances into a new epoch. type EpochTrio struct { - Prev *Epoch // nil if Current is epoch 0 or predecessor not yet seeded + Prev utils.Option[*Epoch] // absent if Current is epoch 0 Current *Epoch - Next *Epoch // nil if successor not yet seeded + Next *Epoch } // all returns the three epochs in priority order: Current first, then Next, then Prev. // This ensures EpochForRoad matches the most-likely epoch first and prevents an // open-range Prev from shadowing Current or Next. -func (w EpochTrio) all() [3]*Epoch { - return [3]*Epoch{w.Current, w.Next, w.Prev} +func (w EpochTrio) all() [3]utils.Option[*Epoch] { + return [3]utils.Option[*Epoch]{utils.Some(w.Current), utils.Some(w.Next), w.Prev} } // EpochForRoad returns the epoch whose road range contains roadIdx. func (w EpochTrio) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { - for _, ep := range w.all() { - if ep != nil && ep.RoadRange().Has(roadIdx) { + for _, oep := range w.all() { + if ep, ok := oep.Get(); ok && ep.RoadRange().Has(roadIdx) { return ep, nil } } @@ -31,10 +36,8 @@ func (w EpochTrio) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { func (w EpochTrio) CurrentAndNextLanes() map[LaneID]struct{} { lanes := make(map[LaneID]struct{}) for _, ep := range [2]*Epoch{w.Current, w.Next} { - if ep != nil { - for lane := range ep.Committee().Lanes().All() { - lanes[lane] = struct{}{} - } + for lane := range ep.Committee().Lanes().All() { + lanes[lane] = struct{}{} } } return lanes @@ -45,8 +48,8 @@ func (w EpochTrio) CurrentAndNextLanes() map[LaneID]struct{} { // boundary QC that spans the epoch transition has been fully collected. func (w EpochTrio) AllLanes() map[LaneID]struct{} { lanes := make(map[LaneID]struct{}) - for _, ep := range w.all() { - if ep != nil { + for _, oep := range w.all() { + if ep, ok := oep.Get(); ok { for lane := range ep.Committee().Lanes().All() { lanes[lane] = struct{}{} } @@ -59,7 +62,7 @@ func (w EpochTrio) AllLanes() map[LaneID]struct{} { // Use for votes and blocks, which must belong to the current or upcoming epoch. func (w EpochTrio) VerifyInWindow(fn func(*Committee) error) (*Epoch, error) { for _, ep := range [2]*Epoch{w.Current, w.Next} { - if ep != nil && fn(ep.Committee()) == nil { + if fn(ep.Committee()) == nil { return ep, nil } } @@ -70,8 +73,8 @@ func (w EpochTrio) VerifyInWindow(fn func(*Committee) error) (*Epoch, error) { func (w EpochTrio) String() string { s := "epochs [" sep := "" - for _, ep := range w.all() { - if ep != nil { + for _, oep := range w.all() { + if ep, ok := oep.Get(); ok { s += fmt.Sprintf("%s%d", sep, ep.EpochIndex()) sep = ", " } diff --git a/sei-tendermint/autobahn/types/epoch_trio_test.go b/sei-tendermint/autobahn/types/epoch_trio_test.go index 8fd4d1c8b7..62e3914395 100644 --- a/sei-tendermint/autobahn/types/epoch_trio_test.go +++ b/sei-tendermint/autobahn/types/epoch_trio_test.go @@ -40,7 +40,7 @@ func TestEpochForRoad_HitsCurrentEpoch(t *testing.T) { func TestEpochForRoad_HitsPrevEpoch(t *testing.T) { prev, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Prev: prev, Current: current, Next: next} + w := types.EpochTrio{Prev: utils.Some(prev), Current: current, Next: next} ep, err := w.EpochForRoad(50) if err != nil { t.Fatalf("EpochForRoad(50): %v", err) @@ -83,7 +83,8 @@ func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { committee := utils.OrPanic1(types.NewCommittee(weights)) openEpoch := types.NewEpoch(0, types.OpenRoadRange(), utils.GenTimestamp(rng), committee, 1) current := types.NewEpoch(1, types.RoadRange{First: 100, Last: 199}, utils.GenTimestamp(rng), committee, 101) - w := types.EpochTrio{Prev: openEpoch, Current: current} + next := types.NewEpoch(2, types.RoadRange{First: 200, Last: 299}, utils.GenTimestamp(rng), committee, 201) + w := types.EpochTrio{Prev: utils.Some(openEpoch), Current: current, Next: next} ep, err := w.EpochForRoad(150) if err != nil { t.Fatalf("EpochForRoad(150): %v", err) @@ -94,12 +95,12 @@ func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { } } -func TestEpochForRoad_NilPrevSkipped(t *testing.T) { +func TestEpochForRoad_AbsentPrevSkipped(t *testing.T) { _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Prev: nil, Current: current, Next: next} - // Road 50 belongs to prev, which is nil — should return error, not panic. + w := types.EpochTrio{Current: current, Next: next} + // Road 50 belongs to prev, which is absent — should return error, not panic. if _, err := w.EpochForRoad(50); err == nil { - t.Fatal("EpochForRoad(50) with nil Prev expected error, got nil") + t.Fatal("EpochForRoad(50) with absent Prev expected error, got nil") } } @@ -121,17 +122,6 @@ func TestCurrentAndNextLanes_UnionOfBoth(t *testing.T) { } } -func TestCurrentAndNextLanes_NilNextOmitted(t *testing.T) { - _, current, _, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: nil} - lanes := w.CurrentAndNextLanes() - for lane := range current.Committee().Lanes().All() { - if _, ok := lanes[lane]; !ok { - t.Fatalf("lane %v from Current missing from result", lane) - } - } -} - // --- VerifyInWindow --- func TestVerifyInWindow_MatchesCurrent(t *testing.T) { @@ -175,7 +165,7 @@ func TestVerifyInWindow_NoneMatchReturnsError(t *testing.T) { func TestVerifyInWindow_SkipsPrev(t *testing.T) { prev, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Prev: prev, Current: current, Next: next} + w := types.EpochTrio{Prev: utils.Some(prev), Current: current, Next: next} callCount := 0 _, _ = w.VerifyInWindow(func(*types.Committee) error { callCount++ diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index a91bedf42b..f36806bdb8 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -5,7 +5,7 @@ import ( ) // laneVoteSet tracks weighted votes for a single block hash. -// header is set on the first vote and never cleared by reweight, so callers +// header is set on the first vote and never cleared by reset, so callers // can always recover the block header even after committee rotation empties votes. type laneVoteSet struct { weight uint64 @@ -13,7 +13,7 @@ type laneVoteSet struct { header *types.BlockHeader } -func (s *laneVoteSet) reweight() { +func (s *laneVoteSet) reset() { s.weight = 0 s.votes = s.votes[:0] } @@ -63,12 +63,12 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot // newEpoch's committee. Called when the epoch advances so that votes from // validators who were in the next epoch are now counted. Returns true if any // block hash newly reached quorum under the new committee. -// Each laneVoteSet.reweight() clears weight and votes but preserves header, +// Each laneVoteSet.reset() clears weight and votes but preserves header, // so headers() can always recover block headers even after committee rotation. func (bv blockVotes) reweight(newEpoch *types.Epoch) bool { c := newEpoch.Committee() for _, set := range bv.byHash { - set.reweight() + set.reset() } quorumReached := false for k, vote := range bv.byKey { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 0fbc54a604..4ae3b62a62 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -136,8 +136,8 @@ func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { } // TrioAt returns the EpochTrio centered on the epoch containing roadIndex. -// During the seeding phase, missing epochs are auto-generated (same as EpochAt). -// Returns an error if Current or Next is not in the registry after seeding. +// Current and Next must already be present in the registry (callers seed them); +// returns an error if either is missing. Prev is absent only when Current is epoch 0. func (r *Registry) TrioAt(roadIndex types.RoadIndex) (types.EpochTrio, error) { centerIdx := types.EpochIndex(roadIndex / EpochLength) current, err := r.EpochAt(types.RoadIndex(centerIdx) * EpochLength) @@ -150,7 +150,9 @@ func (r *Registry) TrioAt(roadIndex types.RoadIndex) (types.EpochTrio, error) { } trio := types.EpochTrio{Current: current, Next: next} if centerIdx > 0 { - trio.Prev, _ = r.EpochAt(types.RoadIndex(centerIdx-1) * EpochLength) + if prev, err := r.EpochAt(types.RoadIndex(centerIdx-1) * EpochLength); err == nil { + trio.Prev = utils.Some(prev) + } } return trio, nil } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index f2f62a369f..2d643d9146 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -138,8 +138,8 @@ func TestTrioAt_GenesisEpoch(t *testing.T) { if err != nil { t.Fatalf("TrioAt(0) error: %v", err) } - if trio.Prev != nil { - t.Fatalf("TrioAt(0).Prev = %v, want nil for epoch 0", trio.Prev) + if trio.Prev.IsPresent() { + t.Fatalf("TrioAt(0).Prev = %v, want absent for epoch 0", trio.Prev) } if trio.Current == nil || trio.Current.EpochIndex() != 0 { t.Fatalf("TrioAt(0).Current.EpochIndex() wrong, want 0") @@ -151,12 +151,13 @@ func TestTrioAt_GenesisEpoch(t *testing.T) { func TestTrioAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) - // During seeding, TrioAt auto-generates any missing neighbor epochs. + // During seeding, EpochAt auto-generates missing epochs when TrioAt looks them up. trio, err := r.TrioAt(2 * EpochLength) if err != nil { t.Fatalf("TrioAt(epoch 2) error: %v", err) } - if trio.Prev == nil || trio.Prev.EpochIndex() != 1 { + prev, ok := trio.Prev.Get() + if !ok || prev.EpochIndex() != 1 { t.Fatalf("TrioAt(epoch 2).Prev.EpochIndex() wrong, want 1") } if trio.Current == nil || trio.Current.EpochIndex() != 2 { From ef1e401c55be59378bfe590d1b2446081df3e60a Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 16:10:54 -0700 Subject: [PATCH 14/98] refactor(autobahn): per-epoch lane vote accumulation and restart epoch split Track lane votes per epoch instead of reweighting the current-epoch set at each boundary. pushVote credits a vote to the Current and Next epochs it is valid under (via a shared credit helper); advanceEpochLanes back-fills a newly-entering epoch's sets from stored votes so a block finalized under it (lagging lane, or shared committee) still reaches quorum. laneQC reads the current epoch's set directly. pushVote reports quorum only for the current epoch; the boundary CommitQC's ctrl.Updated() surfaces the incoming epoch's already-formed laneQCs. headers() recovers block headers from votes[0]. consensus/newInner: verify the persisted CommitQC against its own epoch while stamping the current view (and its votes/QCs) with the next-view epoch, so a CommitQC on the last road of an epoch validates on restart. data.State.EpochTrio returns by value; giga routers read it fresh instead of caching, and share a newGigaRouterCommon constructor. Align avail EpochForRoad error labels; map a lost-epoch race in fullCommitQC to ErrPruned. Co-Authored-By: Claude Opus 4.8 --- .../internal/autobahn/avail/block_votes.go | 135 ++++++++------ .../autobahn/avail/block_votes_test.go | 171 +++++++++++------- .../internal/autobahn/avail/inner.go | 34 ++-- .../internal/autobahn/avail/inner_test.go | 20 +- .../internal/autobahn/avail/state.go | 58 ++++-- .../internal/autobahn/consensus/inner.go | 17 +- .../autobahn/consensus/persisted_inner.go | 21 ++- .../internal/autobahn/data/state.go | 7 +- .../internal/p2p/giga_router_common.go | 38 +++- .../internal/p2p/giga_router_fullnode.go | 18 +- .../internal/p2p/giga_router_validator.go | 21 +-- 11 files changed, 322 insertions(+), 218 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index f36806bdb8..86cb8b579d 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -4,88 +4,109 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" ) -// laneVoteSet tracks weighted votes for a single block hash. -// header is set on the first vote and never cleared by reset, so callers -// can always recover the block header even after committee rotation empties votes. +// laneVoteSet accumulates weighted votes for one block hash under a single +// epoch's committee. The epoch it belongs to is the key in blockVotes.byHash, +// so the epoch a weight is valid for is always explicit. type laneVoteSet struct { weight uint64 votes []*types.Signed[*types.LaneVote] - header *types.BlockHeader } -func (s *laneVoteSet) reset() { - s.weight = 0 - s.votes = s.votes[:0] +// add records vote's weight and returns true iff the set newly reached quorum +// on this vote. It is a no-op (returns false) once quorum is already met. +func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) bool { + if s.weight >= quorum { + return false + } + s.weight += weight + s.votes = append(s.votes, vote) + return s.weight >= quorum } +// blockVotes tracks votes for a single (lane, block number) across the epochs +// in the current trio window. Weight is accumulated per epoch independently, +// so advancing the epoch needs no reweight: the next epoch's laneVoteSet has +// been filled in parallel as votes arrived. A vote can only reach here after +// EpochTrio.VerifyInWindow accepts it against Current or Next, so accumulating +// into exactly those two epochs is complete. type blockVotes struct { - byKey map[types.PublicKey]*types.Signed[*types.LaneVote] - byHash map[types.BlockHeaderHash]*laneVoteSet + // byKey dedups votes: one vote per signer per block. + byKey map[types.PublicKey]*types.Signed[*types.LaneVote] + // byHash maps a block hash to its per-epoch vote sets. Every set is + // non-empty (created only when a weighted vote is appended), so headers() + // can recover the block header from any set's votes[0]. + byHash map[types.BlockHeaderHash]map[types.EpochIndex]*laneVoteSet } func newBlockVotes() blockVotes { return blockVotes{ byKey: map[types.PublicKey]*types.Signed[*types.LaneVote]{}, - byHash: map[types.BlockHeaderHash]*laneVoteSet{}, + byHash: map[types.BlockHeaderHash]map[types.EpochIndex]*laneVoteSet{}, } } -// pushVote may store the vote for the current and next Epoch, but only -// accumulates weight for currentEpoch. -// Returns true iff a new QC has been constructed. -func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) (*types.LaneQC, bool) { - c := ep.Committee() +// pushVote records vote and accumulates its weight into the laneVoteSet of +// every epoch in eps under which the signer has non-zero weight. Callers pass +// the current and next epochs; a signer present only in the next epoch counts +// toward the next epoch's set immediately, so no reweight is needed when the +// epoch advances. +// +// Returns true iff the current epoch (eps[0]) newly reached its lane quorum on +// this vote — the signal for the caller to wake WaitForLaneQCs, which reads the +// current epoch's set. A quorum that forms only in the next epoch's set is +// silent; it is picked up when the boundary advance wakes waiters. +func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.LaneVote]) bool { k := vote.Key() - h := vote.Msg().Header().Hash() if _, ok := bv.byKey[k]; ok { - return nil, false + return false } bv.byKey[k] = vote - byHash, ok := bv.byHash[h] - if !ok { - byHash = &laneVoteSet{header: vote.Msg().Header()} - bv.byHash[h] = byHash - } - if byHash.weight >= c.LaneQuorum() { - return nil, false + + // Ensure the per-epoch map exists so credit (shared with applyEpoch) can + // assume it. + h := vote.Msg().Header().Hash() + if _, ok := bv.byHash[h]; !ok { + bv.byHash[h] = map[types.EpochIndex]*laneVoteSet{} } - if w := c.Weight(k); w != 0 { - byHash.weight += w - byHash.votes = append(byHash.votes, vote) - if byHash.weight >= c.LaneQuorum() { - return types.NewLaneQC(byHash.votes), true + + notify := false + for i, ep := range eps { + // Notify only for the current epoch (eps[0]). + if bv.credit(ep, vote) && i == 0 { + notify = true } } - return nil, false + return notify } -// reweight recalculates weights and vote lists for all stored votes using -// newEpoch's committee. Called when the epoch advances so that votes from -// validators who were in the next epoch are now counted. Returns true if any -// block hash newly reached quorum under the new committee. -// Each laneVoteSet.reset() clears weight and votes but preserves header, -// so headers() can always recover block headers even after committee rotation. -func (bv blockVotes) reweight(newEpoch *types.Epoch) bool { - c := newEpoch.Committee() - for _, set := range bv.byHash { - set.reset() +// credit adds vote to ep's set for the vote's block hash and returns whether +// ep's set newly reached quorum. A no-op for signers with no weight in ep. The +// byHash entry for the vote's block must already exist (pushVote creates it; +// applyEpoch only iterates votes that were pushed). +func (bv blockVotes) credit(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { + c := ep.Committee() + w := c.Weight(vote.Key()) + if w == 0 { + return false } - quorumReached := false - for k, vote := range bv.byKey { - w := c.Weight(k) - if w == 0 { - continue - } - h := vote.Msg().Header().Hash() - set := bv.byHash[h] - if set.weight >= c.LaneQuorum() { - continue - } - set.weight += w - set.votes = append(set.votes, vote) - if set.weight >= c.LaneQuorum() { - quorumReached = true - } + byEpoch := bv.byHash[vote.Msg().Header().Hash()] + set := byEpoch[ep.EpochIndex()] + if set == nil { + set = &laneVoteSet{} + byEpoch[ep.EpochIndex()] = set + } + return set.add(w, c.LaneQuorum(), vote) +} + +// applyEpoch credits every stored vote to ep's set. It is called when ep newly +// enters the trio window as the Next epoch: a vote is only ever credited to the +// Current and Next epochs at push time, so a block whose votes arrived before +// ep was in range — yet finalizes under ep (e.g. a lagging lane, or ep sharing +// the prior epoch's committee) — would otherwise have an empty set under ep and +// never reach quorum. ep's sets are empty beforehand (ep was neither Current +// nor Next until now), so back-filling from byKey does not double-count. +func (bv blockVotes) applyEpoch(ep *types.Epoch) { + for _, vote := range bv.byKey { + bv.credit(ep, vote) } - return quorumReached } diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 3cbe4a71a6..89146d3c82 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -9,97 +9,132 @@ import ( "github.com/stretchr/testify/require" ) -// TestPushVote_ZeroWeightSignerExcludedFromQC verifies that a signer with -// zero weight in the current epoch (e.g. a next-epoch-only validator whose -// vote was accepted via VerifyInWindow) is stored in byKey but does NOT -// appear in byHash.votes or the resulting LaneQC. Including such a signature -// would cause laneQC.Verify to fail on peers when they check all sigs against -// the current committee. -func TestPushVote_ZeroWeightSignerExcludedFromQC(t *testing.T) { +func makeVoteEpoch(idx types.EpochIndex, weights map[types.PublicKey]uint64) *types.Epoch { + c := utils.OrPanic1(types.NewCommittee(weights)) + first := types.RoadIndex(uint64(idx) * 108_000) + rr := types.RoadRange{First: first, Last: first + 107_999} + return types.NewEpoch(idx, rr, time.Time{}, c, 0) +} + +// TestPushVote_AccumulatesPerEpoch verifies that a vote is credited only to the +// epochs under which its signer has weight, each tracked in its own laneVoteSet. +// A next-epoch-only signer contributes to the next epoch's set but leaves the +// current epoch's set untouched, so no reweight is needed at the boundary. +func TestPushVote_AccumulatesPerEpoch(t *testing.T) { rng := utils.TestRng() - keyA := types.GenSecretKey(rng) // in current epoch - keyE := types.GenSecretKey(rng) // next-epoch-only (weight=0 in current) + keyA := types.GenSecretKey(rng) // current epoch only + keyE := types.GenSecretKey(rng) // next epoch only - makeEpoch := func(idx types.EpochIndex, weights map[types.PublicKey]uint64) *types.Epoch { - c := utils.OrPanic1(types.NewCommittee(weights)) - first := types.RoadIndex(uint64(idx) * 108_000) - rr := types.RoadRange{First: first, Last: first + 107_999} - return types.NewEpoch(idx, rr, time.Time{}, c, 0) - } - - ep0 := makeEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) + ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) + ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{keyE.Public(): 1}) + eps := []*types.Epoch{ep0, ep1} lane := keyA.Public() block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) header := block.Header() + h := header.Hash() bv := newBlockVotes() - // E votes first; weight=0 in ep0 so must not appear in byHash.votes. - lqc, ok := bv.pushVote(ep0, types.Sign(keyE, types.NewLaneVote(header))) - require.Nil(t, lqc) - require.False(t, ok) - require.Contains(t, bv.byKey, keyE.Public(), "E's vote must be stored in byKey for future reweight") - entry := bv.byHash[header.Hash()] - require.Empty(t, entry.votes, "E's zero-weight vote must not appear in byHash.votes") - - // A votes and reaches quorum; the resulting LaneQC must contain only A. - lqc, ok = bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))) - require.NotNil(t, lqc) - require.True(t, ok) - entry = bv.byHash[header.Hash()] - require.Len(t, entry.votes, 1, "only A's vote should be in byHash.votes") - require.Equal(t, keyA.Public(), entry.votes[0].Key(), "the sole vote must be A's") + // E votes first: weight 0 in ep0, 1 in ep1 (ep1 LaneQuorum == 1). E reaches + // the next epoch's quorum but NOT the current epoch's, so pushVote reports + // nothing — WaitForLaneQCs only cares about the current epoch. + require.False(t, bv.pushVote(eps, types.Sign(keyE, types.NewLaneVote(header))), + "a next-epoch-only quorum must not notify") + require.Contains(t, bv.byKey, keyE.Public()) + + byEpoch := bv.byHash[h] + _, inEp0 := byEpoch[0] + require.False(t, inEp0, "E has no weight in ep0; the ep0 set must not be created") + set1 := byEpoch[1] + require.NotNil(t, set1) + require.Len(t, set1.votes, 1, "E's vote is credited to ep1 only") + require.Equal(t, keyE.Public(), set1.votes[0].Key()) + // The block header is recoverable from any set's votes[0] (no dedicated field). + require.Equal(t, header, set1.votes[0].Msg().Header()) + + // A votes: weight 1 in ep0, 0 in ep1. Reaches the ep0 quorum → notifies. + require.True(t, bv.pushVote(eps, types.Sign(keyA, types.NewLaneVote(header)))) + set0 := byEpoch[0] + require.NotNil(t, set0) + require.Len(t, set0.votes, 1, "only A's vote is in the ep0 set") + require.Equal(t, keyA.Public(), set0.votes[0].Key()) + require.Len(t, set1.votes, 1, "A's vote does not leak into the ep1 set") } -// TestReweightPreservesHeader verifies that after reweight empties the votes -// slice (because committee rotation removed all voters for a block hash), the -// block header stored at first-vote time is still accessible. This matters -// because headers() reads entry.header to build the parent-hash chain for -// fullCommitQC, and that call arrives after reweightForNextEpoch has run. -func TestReweightPreservesHeader(t *testing.T) { +// TestApplyEpoch_BackfillsFromStoredVotes verifies that when an epoch newly +// enters the window, its set is seeded from votes that arrived before it was in +// range. This covers a block voted while Current=N but finalized under N+2 with +// an identical committee: without back-fill its N+2 set would be empty. +func TestApplyEpoch_BackfillsFromStoredVotes(t *testing.T) { rng := utils.TestRng() - - // Epoch 0: key A only. Epoch 1: key B only (disjoint committees). - // LaneQuorum for a 1-validator committee is 1, so A's vote alone reaches quorum. - // After reweight to ep1, A's vote is cleared from votes — but header must survive. keyA := types.GenSecretKey(rng) keyB := types.GenSecretKey(rng) - - makeEpoch := func(idx types.EpochIndex, weights map[types.PublicKey]uint64) *types.Epoch { - c := utils.OrPanic1(types.NewCommittee(weights)) - first := types.RoadIndex(uint64(idx) * 108_000) - rr := types.RoadRange{First: first, Last: first + 107_999} - return types.NewEpoch(idx, rr, time.Time{}, c, 0) + keyC := types.GenSecretKey(rng) + keyD := types.GenSecretKey(rng) + weights := map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, } + // ep0=current, ep1=next at push time; ep2 shares the same committee. + ep0 := makeVoteEpoch(0, weights) + ep1 := makeVoteEpoch(1, weights) + ep2 := makeVoteEpoch(2, weights) + + lane := keyA.Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + header := block.Header() + h := header.Hash() + + bv := newBlockVotes() + // A votes while the window is {ep0, ep1}: credited to ep0 and ep1 only. + bv.pushVote([]*types.Epoch{ep0, ep1}, types.Sign(keyA, types.NewLaneVote(header))) + _, hasEp2 := bv.byHash[h][2] + require.False(t, hasEp2, "ep2 not credited at push time") + + // ep2 enters the window as Next → back-fill from stored votes. + bv.applyEpoch(ep2) + set2 := bv.byHash[h][2] + require.NotNil(t, set2, "ep2 set seeded on entry") + require.Len(t, set2.votes, 1) + require.Equal(t, keyA.Public(), set2.votes[0].Key()) + require.Equal(t, uint64(1), set2.weight) + + // Idempotency guard within a single entry: a later signer arriving via + // pushVote (window now {ep1, ep2}) adds to ep2 without disturbing A's credit. + bv.pushVote([]*types.Epoch{ep1, ep2}, types.Sign(keyB, types.NewLaneVote(header))) + require.Len(t, set2.votes, 2, "B added to ep2 after entry") +} - ep0 := makeEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) - ep1 := makeEpoch(1, map[types.PublicKey]uint64{keyB.Public(): 1}) +// TestPushVote_DedupsSigner verifies a signer's second vote for the same block +// is ignored (byKey dedup), so weight is never double-counted. +func TestPushVote_DedupsSigner(t *testing.T) { + rng := utils.TestRng() + keyA := types.GenSecretKey(rng) + // Four validators (LaneQuorum = Faulty()+1 = 2) so a single vote is below + // quorum and the effect of a duplicate is observable. + keyB := types.GenSecretKey(rng) + keyC := types.GenSecretKey(rng) + keyD := types.GenSecretKey(rng) + weights := map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, + } + // Distinct current/next epochs (as in production) with the same committee. + eps := []*types.Epoch{makeVoteEpoch(0, weights), makeVoteEpoch(1, weights)} lane := keyA.Public() block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) header := block.Header() bv := newBlockVotes() + vote := types.Sign(keyA, types.NewLaneVote(header)) - // A's vote reaches quorum immediately (LaneQuorum=1). - lqc, ok := bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))) - require.NotNil(t, lqc, "quorum should be reached on A's vote") - require.True(t, ok) + require.False(t, bv.pushVote(eps, vote), "one of four validators is below quorum (2)") + set := bv.byHash[header.Hash()][0] + require.Equal(t, uint64(1), set.weight) - h := header.Hash() - entry := bv.byHash[h] - require.NotNil(t, entry.header, "header should be set after pushVote") - require.Equal(t, header, entry.header) - require.Len(t, entry.votes, 1) - - // Reweight to epoch 1: keyA and keyB have weight 0, so votes is emptied. - bv.reweight(ep1) - - entry = bv.byHash[h] - require.Empty(t, entry.votes, "votes should be empty after reweight removes old-committee voters") - require.Equal(t, uint64(0), entry.weight) - require.NotNil(t, entry.header, "header must survive reweight") - require.Equal(t, header, entry.header, "header content must be unchanged after reweight") + // Re-pushing A's vote must not add weight again. + require.False(t, bv.pushVote(eps, vote)) + require.Equal(t, uint64(1), set.weight, "duplicate vote must not double-count") + require.Len(t, set.votes, 1) } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 7500f4df97..975fc805dc 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -115,7 +115,7 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt // Restore persisted blocks. Create queues on demand for any lane present // in the WAL — lanes outside the current epoch will be pruned by - // reweightForNextEpoch in NewState if a boundary was crossed. + // advanceEpochLanes in NewState if a boundary was crossed. for lane, bs := range l.blocks { if len(bs) == 0 { continue @@ -152,19 +152,26 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt } func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, trio types.EpochTrio) (*types.LaneQC, bool) { - quorum := trio.Current.Committee().LaneQuorum() - for _, byHash := range i.votes[lane].q[n].byHash { - if byHash.weight >= quorum { - return types.NewLaneQC(byHash.votes), true + c := trio.Current.Committee() + quorum := c.LaneQuorum() + epIdx := trio.Current.EpochIndex() + for _, byEpoch := range i.votes[lane].q[n].byHash { + if set, ok := byEpoch[epIdx]; ok && set.weight >= quorum { + return types.NewLaneQC(set.votes), true } } return nil, false } -// reweightForNextEpoch initializes queues for any new lanes in nextTrio and -// recalculates vote weights across all lanes using the new committee. -// Returns true if any block newly reached quorum under the new committee. -func (i *inner) reweightForNextEpoch(nextTrio types.EpochTrio) bool { +// advanceEpochLanes rotates the lane set for a crossing into nextTrio: it +// creates queues for lanes newly introduced by the next epoch, drops lanes no +// longer in the window, and back-fills the newly-entering Next epoch's vote +// sets. pushVote credits each vote to the Current and Next epochs only, so when +// nextTrio.Next first enters the window its sets are empty; applyEpoch seeds +// them from stored votes so a block that finalizes under it (a lagging lane, or +// an identical committee) still reaches quorum. The prior Current/Next sets +// were already filled by pushVote, so they need no adjustment. +func (i *inner) advanceEpochLanes(nextTrio types.EpochTrio) { activeLanes := nextTrio.CurrentAndNextLanes() for lane := range activeLanes { if _, ok := i.blocks[lane]; !ok { @@ -181,7 +188,7 @@ func (i *inner) reweightForNextEpoch(nextTrio types.EpochTrio) bool { } } // Retain Prev-epoch lanes in the deletion guard: fullCommitQC may still be - // collecting headers from the boundary QC that triggered this reweight, and + // collecting headers from the boundary QC that triggered this advance, and // it accesses lane queues by epoch N's committee. Prev lanes are cleaned up // naturally on the next epoch advance when they fall outside AllLanes(). retainLanes := nextTrio.AllLanes() @@ -193,15 +200,12 @@ func (i *inner) reweightForNextEpoch(nextTrio types.EpochTrio) bool { delete(i.persistedBlockStart, lane) } } - quorumReached := false + // Seed the newly-entering Next epoch's vote sets from votes already stored. for _, voteQueue := range i.votes { for n := voteQueue.first; n < voteQueue.next; n++ { - if voteQueue.q[n].reweight(nextTrio.Current) { - quorumReached = true - } + voteQueue.q[n].applyEpoch(nextTrio.Next) } } - return quorumReached } // prune advances the state to account for a new AppQC/CommitQC pair. diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 86bb9685a1..fb66f68c26 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -840,7 +840,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { require.Equal(t, types.RoadIndex(3), i.commitQCs.next) } -func TestReweightForNextEpoch_AddsAndRemovesLanes(t *testing.T) { +func TestAdvanceEpochLanes_AddsAndRemovesLanes(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) trio := utils.OrPanic1(registry.TrioAt(0)) @@ -866,13 +866,13 @@ func TestReweightForNextEpoch_AddsAndRemovesLanes(t *testing.T) { i.nextBlockToPersist[bogusLane] = 0 i.persistedBlockStart[bogusLane] = 0 - // reweightForNextEpoch removes the bogus lane and keeps the real one. - i.reweightForNextEpoch(trio) + // advanceEpochLanes removes the bogus lane and keeps the real one. + i.advanceEpochLanes(trio) require.NotContains(t, i.blocks, bogusLane, "decommissioned lane still present") require.Contains(t, i.blocks, realLane, "active lane removed incorrectly") } -func TestReweightForNextEpoch_EmptyQueuesReturnsFalse(t *testing.T) { +func TestAdvanceEpochLanes_EmptyQueuesNoop(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) trio := utils.OrPanic1(registry.TrioAt(0)) @@ -880,11 +880,15 @@ func TestReweightForNextEpoch_EmptyQueuesReturnsFalse(t *testing.T) { i, err := newInner(trio, utils.None[*loadedAvailState]()) require.NoError(t, err) - // No votes in any queue; reweight should not signal quorum. - require.False(t, i.reweightForNextEpoch(trio)) + // No votes in any queue; advancing to the same trio is a safe no-op that + // keeps the current lane set intact. + i.advanceEpochLanes(trio) + for lane := range trio.Current.Committee().Lanes().All() { + require.Contains(t, i.blocks, lane) + } } -func TestReweightForNextEpoch_RetainsPrevEpochLanes(t *testing.T) { +func TestAdvanceEpochLanes_RetainsPrevEpochLanes(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) @@ -903,7 +907,7 @@ func TestReweightForNextEpoch_RetainsPrevEpochLanes(t *testing.T) { // trio1: Prev=epoch0, Current=epoch1, Next=epoch2 trio1 := utils.OrPanic1(registry.TrioAt(epoch.EpochLength)) - i.reweightForNextEpoch(trio1) + i.advanceEpochLanes(trio1) // Epoch0 lane is now in Prev — must be retained for boundary QC collection. require.Contains(t, i.blocks, epoch0Lane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index cd9011659f..218add7b95 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -180,7 +180,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin if err != nil { return nil, fmt.Errorf("TrioAt(%d): %w", inner.commitQCs.next, err) } - inner.reweightForNextEpoch(nextTrio) + inner.advanceEpochLanes(nextTrio) finalTrio = nextTrio } @@ -296,7 +296,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // one — both are valid for verifying a CommitQC at idx. ep, err := s.epochTrio.Load().EpochForRoad(idx) if err != nil { - return fmt.Errorf("EpochAt(%d): %w", idx, err) + return fmt.Errorf("EpochForRoad(%d): %w", idx, err) } if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) @@ -305,7 +305,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { if idx != inner.commitQCs.next { return nil } - // At an epoch boundary, initialize lanes for the next epoch. + // At an epoch boundary, rotate the lane set to the next epoch. // N+2 is seeded by executeBlock (AdvanceIfNeeded) on every block in epoch N, // so it is present well before the boundary CommitQC arrives. A miss here // is a registry bug — fail loudly rather than continuing with stale state. @@ -314,11 +314,14 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { if err != nil { return fmt.Errorf("TrioAt(%d): %w", idx+1, err) } - quorumReached := inner.reweightForNextEpoch(nextTrio) + inner.advanceEpochLanes(nextTrio) s.epochTrio.Store(&nextTrio) - if quorumReached { - ctrl.Updated() - } + // The trailing ctrl.Updated() below is the ONLY wake that surfaces + // laneQCs already at quorum in the incoming Current (old Next) epoch: + // pushVote credited those votes silently (it notifies only for the + // then-current epoch), so WaitForLaneQCs must be woken here to re-scan + // laneQC under the just-stored trio. Do not make it conditional or the + // boundary stalls. } inner.commitQCs.pushBack(qc) metrics.ObserveCommitQC(qc) @@ -336,7 +339,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] idx := v.Msg().Proposal().RoadIndex() ep, err := s.epochTrio.Load().EpochForRoad(idx) if err != nil { - return fmt.Errorf("EpochAt(%d): %w", idx, err) + return fmt.Errorf("EpochForRoad(%d): %w", idx, err) } committee := ep.Committee() if err := v.VerifySig(committee); err != nil { @@ -388,7 +391,7 @@ func (s *State) PushAppQC(appQC *types.AppQC, commitQC *types.CommitQC) error { } ep, err := s.epochTrio.Load().EpochForRoad(commitQC.Proposal().Index()) if err != nil { - return fmt.Errorf("EpochAt(%d): %w", commitQC.Proposal().Index(), err) + return fmt.Errorf("EpochForRoad(%d): %w", commitQC.Proposal().Index(), err) } if err := appQC.Verify(ep.Committee()); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) @@ -533,10 +536,12 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - // pushVote accumulates weight using the current epoch's committee. - // Votes from next-epoch validators are stored (byKey) but contribute - // zero weight until reweightForNextEpoch runs at the epoch boundary. - if _, ok := q.q[h.BlockNumber()].pushVote(s.epochTrio.Load().Current, vote); ok { + // pushVote accumulates weight per epoch: a vote counts toward every epoch + // in the window under which its signer has weight. Passing Current and + // Next means a next-epoch validator's vote is counted for the next epoch + // immediately, so no reweight is needed when the epoch advances. + trio := s.epochTrio.Load() + if q.q[h.BlockNumber()].pushVote([]*types.Epoch{trio.Current, trio.Next}, vote) { ctrl.Updated() } } @@ -560,12 +565,22 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc if q.first > lr.First() { return nil, data.ErrPruned } - // Check if we have the header. - if entry, ok := q.q[n].byHash[want]; ok { - h := entry.header - want = h.ParentHash() - headers[len(headers)-i-1] = h - break + // Check if we have the header. Every per-epoch set for a hash + // is non-empty, and all its votes carry that hash's header, so + // votes[0] of any set recovers it. + if byEpoch, ok := q.q[n].byHash[want]; ok { + var h *types.BlockHeader + for _, set := range byEpoch { + if len(set.votes) > 0 { + h = set.votes[0].Msg().Header() + break + } + } + if h != nil { + want = h.ParentHash() + headers[len(headers)-i-1] = h + break + } } // Otherwise, wait. if err := ctrl.Wait(ctx); err != nil { @@ -587,7 +602,10 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful var commitHeaders []*types.BlockHeader ep, err := s.epochTrio.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { - return nil, fmt.Errorf("EpochAt(%d): %w", qc.Proposal().Index(), err) + // The epoch window can advance twice between CommitQC() returning and this + // lookup, dropping this QC's road below the window. Its blocks are then + // below the prune cursor, so treat it as pruned (the caller skips pruned QCs). + return nil, fmt.Errorf("epoch window advanced past road %d: %w", qc.Proposal().Index(), data.ErrPruned) } for lane := range ep.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index d88dd15bb0..6d683c4ed5 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -118,18 +118,29 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( persisted = *decoded } + // The runtime epoch is the current view's epoch (NextIndexOpt(CommitQC)): + // View() must stamp the next view with the epoch it belongs to. But the + // persisted CommitQC must be verified against its own epoch, which differs + // from the view epoch when the CommitQC is on the last road of an epoch. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) - startEpoch, err := registry.EpochAt(nextViewRoad) + viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) } - if err := persisted.validate(startEpoch); err != nil { + commitEpoch := viewEpoch + if cqc, ok := persisted.CommitQC.Get(); ok { + commitEpoch, err = registry.EpochAt(cqc.Proposal().Index()) + if err != nil { + return inner{}, fmt.Errorf("EpochAt(%d): %w", cqc.Proposal().Index(), err) + } + } + if err := persisted.validate(commitEpoch, viewEpoch); err != nil { return inner{}, err } logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - return inner{persistedInner: persisted, registry: registry, epoch: startEpoch}, nil + return inner{persistedInner: persisted, registry: registry, epoch: viewEpoch}, nil } func (s *State) pushCommitQC(qc *types.CommitQC) error { diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 46e947a672..542ca7596f 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -71,9 +71,16 @@ type persistedInner struct { // validate checks internal consistency and cryptographic signatures of persisted state. // Returns error on corrupt state. -func (p *persistedInner) validate(ep *types.Epoch) error { +// +// Two epochs are needed at an epoch boundary. commitEp is the epoch of the +// persisted CommitQC (used to verify the CommitQC itself). viewEp is the epoch +// of the current view — NextIndexOpt(CommitQC) — which stamps currentView and +// verifies the current-view artifacts (TimeoutQC, PrepareQC, and this node's +// votes). When the CommitQC sits on the last road of an epoch, commitEp and +// viewEp differ; away from a boundary they are the same epoch. +func (p *persistedInner) validate(commitEp, viewEp *types.Epoch) error { if cqc, ok := p.CommitQC.Get(); ok { - if err := cqc.Verify(ep); err != nil { + if err := cqc.Verify(commitEp); err != nil { return fmt.Errorf("corrupt persisted state: CommitQC failed verification: %w", err) } } @@ -86,14 +93,14 @@ func (p *persistedInner) validate(ep *types.Epoch) error { if tqcIndex != expectedIndex { return fmt.Errorf("corrupt persisted state: TimeoutQC has index %d but expected %d", tqcIndex, expectedIndex) } - if err := tqc.Verify(ep, p.CommitQC); err != nil { + if err := tqc.Verify(viewEp, p.CommitQC); err != nil { return fmt.Errorf("corrupt persisted state: TimeoutQC failed verification: %w", err) } } - vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epoch: ep} + vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epoch: viewEp} currentView := vs.View() - committee := ep.Committee() + committee := viewEp.Committee() // checkViewAndSig validates that a persisted field has the current view and a valid signature. // Since inner is persisted atomically, any view mismatch indicates corrupt state. @@ -109,7 +116,7 @@ func (p *persistedInner) validate(ep *types.Epoch) error { // PrepareQC is required when CommitVote is present (CommitVote requires PrepareQC justification). if pqc, ok := p.PrepareQC.Get(); ok { - if err := checkViewAndSig("PrepareQC", pqc.Proposal().View(), pqc.Verify(ep)); err != nil { + if err := checkViewAndSig("PrepareQC", pqc.Proposal().View(), pqc.Verify(viewEp)); err != nil { return err } } else if p.CommitVote.IsPresent() { @@ -126,7 +133,7 @@ func (p *persistedInner) validate(ep *types.Epoch) error { } } if v, ok := p.TimeoutVote.Get(); ok { - if err := checkViewAndSig("TimeoutVote", v.View(), v.Verify(ep)); err != nil { + if err := checkViewAndSig("TimeoutVote", v.View(), v.Verify(viewEp)); err != nil { return err } } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 28a3cf3d36..7787024d30 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -375,9 +375,10 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { // Registry returns the epoch registry. func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } -// EpochTrio returns the atomic pointer to the current epoch window. -// Callers may read it directly (e.g. EvmProxy) without going through State. -func (s *State) EpochTrio() *atomic.Pointer[types.EpochTrio] { return &s.epochTrio } +// EpochTrio returns a snapshot of the current epoch window. Returned by value +// so callers (e.g. EvmProxy) get an immutable point-in-time view; they must +// call again to observe a boundary advance rather than caching the result. +func (s *State) EpochTrio() types.EpochTrio { return *s.epochTrio.Load() } // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 72442e2b11..df56e611a8 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -35,14 +35,13 @@ import ( const maxInboundFullnodePeers = 10000 type gigaRouterCommon struct { - cfg *GigaRouterCommonConfig - key NodeSecretKey - data *data.State - epochTrio *atomic.Pointer[atypes.EpochTrio] - service *giga.Service - poolIn *giga.Pool[NodePublicKey, rpc.Server[giga.API]] - poolOut *giga.Pool[NodePublicKey, rpc.Client[giga.API]] - app *proxy.Proxy + cfg *GigaRouterCommonConfig + key NodeSecretKey + data *data.State + service *giga.Service + poolIn *giga.Pool[NodePublicKey, rpc.Server[giga.API]] + poolOut *giga.Pool[NodePublicKey, rpc.Client[giga.API]] + app *proxy.Proxy // inboundFullnodeCount tracks live non-committee inbound block-sync // connections. Optimistic Add(1) + compare against cap; over-rejects @@ -56,6 +55,27 @@ type gigaRouterCommon struct { hashVault hashvault.HashVault } +// newGigaRouterCommon assembles the shared router state both roles embed. The +// role-specific service (full RPC vs block-sync) is passed in; the connection +// pools, app handle, and inbound cap are the same for both. +func newGigaRouterCommon( + cfg *GigaRouterCommonConfig, + key NodeSecretKey, + dataState *data.State, + service *giga.Service, +) *gigaRouterCommon { + return &gigaRouterCommon{ + cfg: cfg, + key: key, + data: dataState, + service: service, + poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), + poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), + app: cfg.App, + inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), + } +} + // buildDataState validates the common config and constructs the data // layer (committee, WAL, State) shared by both giga constructors. // @@ -527,6 +547,6 @@ func (r *gigaRouterCommon) RunInboundConn(ctx context.Context, hConn *handshaked // None if the caller should handle it locally. Overridden on // *gigaValidatorRouter to short-circuit self-shard sends. func (r *gigaRouterCommon) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.epochTrio.Load().Current.Committee().EvmShard(sender) + shardValidator := r.data.EpochTrio().Current.Committee().EvmShard(sender) return utils.Some(r.cfg.ValidatorAddrs[shardValidator].EVMRPC) } diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index c27080a6f4..bbe591d7aa 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -8,7 +8,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) @@ -22,20 +21,15 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gig if err != nil { return nil, err } + // Seal seeding once all layers are constructed. On a fullnode data.State is + // the only seeding consumer (no avail/consensus layer), so this is the last + // point that needs the registry to auto-generate WAL-replay epochs. On the + // validator path the equivalent seal lives in consensus.NewState, which + // constructs the final (avail) layer. dataState.Registry().SealSeeding() logger.Info("GigaRouter initialized (fullnode)", "validators", len(cfg.ValidatorAddrs), "dial_interval", cfg.DialInterval, "inbound_fullnode_cap", cfg.MaxInboundFullnodePeers) return &gigaFullnodeRouter{ - gigaRouterCommon: &gigaRouterCommon{ - cfg: cfg, - key: key, - data: dataState, - epochTrio: dataState.EpochTrio(), - service: giga.NewBlockSyncService(dataState), - poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), - poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), - app: cfg.App, - inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), - }, + gigaRouterCommon: newGigaRouterCommon(cfg, key, dataState, giga.NewBlockSyncService(dataState)), }, nil } diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 59b36b0ceb..590040b68f 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -10,7 +10,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) @@ -41,20 +40,10 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV producerState := producer.NewState(cfg.Producer, consensusState, cfg.App) logger.Info("GigaRouter initialized (validator)", "validators", len(cfg.ValidatorAddrs), "dial_interval", cfg.DialInterval, "inbound_fullnode_cap", cfg.MaxInboundFullnodePeers) return &gigaValidatorRouter{ - gigaRouterCommon: &gigaRouterCommon{ - cfg: &cfg.GigaRouterCommonConfig, - key: key, - data: dataState, - epochTrio: dataState.EpochTrio(), - service: giga.NewService(consensusState), - poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), - poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), - app: cfg.App, - inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), - }, - consensus: consensusState, - producer: producerState, - validatorKey: cfg.ValidatorKey.Public(), + gigaRouterCommon: newGigaRouterCommon(&cfg.GigaRouterCommonConfig, key, dataState, giga.NewService(consensusState)), + consensus: consensusState, + producer: producerState, + validatorKey: cfg.ValidatorKey.Public(), }, nil } @@ -90,7 +79,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { // EvmProxy on the validator returns None when the sender's shard owner is // us (handle locally via mempool, no HTTP round-trip to self). func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.epochTrio.Load().Current.Committee().EvmShard(sender) + shardValidator := r.data.EpochTrio().Current.Committee().EvmShard(sender) if r.validatorKey == shardValidator { return utils.None[*url.URL]() } From eedf9d4f794569c5019c8e1efdbc00fd2f5b3e8f Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 16:23:24 -0700 Subject: [PATCH 15/98] fix(autobahn): resolve laneQC against the caller's epoch WaitForLaneQCs iterates the proposal epoch's lanes but passed the cached trio to laneQC, which read vote weight and quorum under trio.Current. When the proposal epoch and trio.Current differ (across an epoch boundary), that reads the wrong per-epoch vote set. laneQC now takes the epoch directly so committee, quorum, epoch index, and lane set are all resolved from the same epoch. Also document that byKey is retained as the applyEpoch back-fill source and freed only when the block number is pruned. Co-Authored-By: Claude Opus 4.8 --- sei-tendermint/internal/autobahn/avail/block_votes.go | 6 +++++- sei-tendermint/internal/autobahn/avail/inner.go | 11 ++++++++--- sei-tendermint/internal/autobahn/avail/state.go | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 86cb8b579d..f451323804 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -30,7 +30,11 @@ func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneV // EpochTrio.VerifyInWindow accepts it against Current or Next, so accumulating // into exactly those two epochs is complete. type blockVotes struct { - // byKey dedups votes: one vote per signer per block. + // byKey dedups votes (one per signer per block) and is the durable record + // applyEpoch replays to back-fill an epoch entering the window. Entries are + // never deleted individually — a signer weight-0 in the current window may + // have weight in a later epoch — the whole map is freed when the block + // number is pruned from the vote queue (inner.prune). byKey map[types.PublicKey]*types.Signed[*types.LaneVote] // byHash maps a block hash to its per-epoch vote sets. Every set is // non-empty (created only when a weighted vote is appended), so headers() diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 975fc805dc..e114bc1181 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -151,10 +151,15 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt return i, nil } -func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, trio types.EpochTrio) (*types.LaneQC, bool) { - c := trio.Current.Committee() +// laneQC returns a LaneQC for (lane, n) under ep — the committee, quorum, and +// per-epoch vote set all resolved from ep. Callers pass the epoch the proposal +// is being built for; using ep (not the cached trio's Current) keeps the lookup +// consistent with the lanes WaitForLaneQCs iterates, which can differ from +// Current across an epoch boundary. +func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, ep *types.Epoch) (*types.LaneQC, bool) { + c := ep.Committee() quorum := c.LaneQuorum() - epIdx := trio.Current.EpochIndex() + epIdx := ep.EpochIndex() for _, byEpoch := range i.votes[lane].q[n].byHash { if set, ok := byEpoch[epIdx]; ok && set.weight >= quorum { return types.NewLaneQC(set.votes), true diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 218add7b95..7f1ff4f9e0 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -641,7 +641,7 @@ func (s *State) WaitForLaneQCs( for lane := range ep.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { - if qc, ok := inner.laneQC(lane, first+i, *s.epochTrio.Load()); ok { + if qc, ok := inner.laneQC(lane, first+i, ep); ok { laneQCs[lane] = qc } else { break From c8eb5736869d8841efaf32ccef0e11727cfd2937 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 16:43:23 -0700 Subject: [PATCH 16/98] fix(autobahn): skip out-of-window CommitQC in PushCommitQC instead of erroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-order processing (waitForCommitQC bounds idx to at most the Next epoch) means a road outside the Prev/Current/Next window can only be below it — a QC already committed. EpochForRoad failing there is not an error; return nil to skip it, matching the stale idx != commitQCs.next case below. Co-Authored-By: Claude Opus 4.8 --- sei-tendermint/internal/autobahn/avail/state.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 7f1ff4f9e0..040bc4d994 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -291,12 +291,16 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - // epochTrio is updated atomically inside the lock at epoch boundaries, so - // this outside-lock read sees either the current epoch or the just-rotated - // one — both are valid for verifying a CommitQC at idx. + // Resolve the epoch that signed this QC. EpochForRoad searches the whole + // Prev/Current/Next window because the cached trio can lag by one across a + // boundary: a QC for the new epoch may arrive while Current is still the old + // one, or land in Prev just after a rotation — both verify correctly here. + // A road outside the window can only be below it (in-order processing via + // waitForCommitQC bounds how far ahead idx can be), i.e. a QC we already + // committed, so it is not needed — skip it like the stale case below. ep, err := s.epochTrio.Load().EpochForRoad(idx) if err != nil { - return fmt.Errorf("EpochForRoad(%d): %w", idx, err) + return nil } if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) From 78864bbff9e6a2c381507e3ed607636b7267b0be Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 17:48:52 -0700 Subject: [PATCH 17/98] fix(autobahn): create byHash entries lazily in credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move byHash entry (and per-epoch set) creation into credit, so an entry exists only once a weighted vote is actually appended. A vote with no weight in any credited epoch — reachable when a verify/credit trio mismatch at a boundary credits against a committee the signer left — now stays only in byKey and creates no byHash entry. headers() therefore never observes an empty per-epoch map. Add a laneVoteSet.add unit test and a zero-weight pushVote test. Co-Authored-By: Claude Opus 4.8 --- .../internal/autobahn/avail/block_votes.go | 28 +++++----- .../autobahn/avail/block_votes_test.go | 53 +++++++++++++++++++ 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index f451323804..7cf6555684 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -36,9 +36,12 @@ type blockVotes struct { // have weight in a later epoch — the whole map is freed when the block // number is pruned from the vote queue (inner.prune). byKey map[types.PublicKey]*types.Signed[*types.LaneVote] - // byHash maps a block hash to its per-epoch vote sets. Every set is - // non-empty (created only when a weighted vote is appended), so headers() - // can recover the block header from any set's votes[0]. + // byHash maps a block hash to its per-epoch vote sets. An entry exists only + // once a weighted vote has been credited (credit creates it lazily), so a + // present entry always has a non-empty set — headers() can recover the block + // header from any set's votes[0]. A vote that earns no weight in any epoch it + // is credited against (e.g. a signer valid only in a now-departed epoch) + // leaves no byHash entry; it lives only in byKey. byHash map[types.BlockHeaderHash]map[types.EpochIndex]*laneVoteSet } @@ -66,13 +69,6 @@ func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.Lane } bv.byKey[k] = vote - // Ensure the per-epoch map exists so credit (shared with applyEpoch) can - // assume it. - h := vote.Msg().Header().Hash() - if _, ok := bv.byHash[h]; !ok { - bv.byHash[h] = map[types.EpochIndex]*laneVoteSet{} - } - notify := false for i, ep := range eps { // Notify only for the current epoch (eps[0]). @@ -85,15 +81,21 @@ func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.Lane // credit adds vote to ep's set for the vote's block hash and returns whether // ep's set newly reached quorum. A no-op for signers with no weight in ep. The -// byHash entry for the vote's block must already exist (pushVote creates it; -// applyEpoch only iterates votes that were pushed). +// byHash entry (and its per-epoch set) is created lazily here, only when a +// weighted vote is actually credited — a vote with no weight in any epoch it is +// credited against leaves no byHash entry, so a present entry is never empty. func (bv blockVotes) credit(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { c := ep.Committee() w := c.Weight(vote.Key()) if w == 0 { return false } - byEpoch := bv.byHash[vote.Msg().Header().Hash()] + h := vote.Msg().Header().Hash() + byEpoch, ok := bv.byHash[h] + if !ok { + byEpoch = map[types.EpochIndex]*laneVoteSet{} + bv.byHash[h] = byEpoch + } set := byEpoch[ep.EpochIndex()] if set == nil { set = &laneVoteSet{} diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 89146d3c82..f3b53e563a 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -16,6 +16,59 @@ func makeVoteEpoch(idx types.EpochIndex, weights map[types.PublicKey]uint64) *ty return types.NewEpoch(idx, rr, time.Time{}, c, 0) } +// TestLaneVoteSet_Add exercises the weight accumulation and quorum edge of the +// laneVoteSet primitive in isolation: it accumulates until quorum, reports the +// crossing exactly once, and is a no-op afterwards. +func TestLaneVoteSet_Add(t *testing.T) { + rng := utils.TestRng() + lane := types.GenSecretKey(rng).Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + mkVote := func() *types.Signed[*types.LaneVote] { + return types.Sign(types.GenSecretKey(rng), types.NewLaneVote(header)) + } + + set := &laneVoteSet{} + // Below quorum (2): accumulates, returns false. + require.False(t, set.add(1, 2, mkVote())) + require.Equal(t, uint64(1), set.weight) + require.Len(t, set.votes, 1) + // Crosses quorum: returns true exactly on the crossing. + require.True(t, set.add(1, 2, mkVote())) + require.Equal(t, uint64(2), set.weight) + require.Len(t, set.votes, 2) + // Already at quorum: no-op, returns false, does not append. + require.False(t, set.add(1, 2, mkVote())) + require.Equal(t, uint64(2), set.weight) + require.Len(t, set.votes, 2) + + // A single heavy vote can cross quorum from empty in one step. + heavy := &laneVoteSet{} + require.True(t, heavy.add(3, 2, mkVote())) + require.Equal(t, uint64(3), heavy.weight) + require.Len(t, heavy.votes, 1) +} + +// TestPushVote_ZeroWeightLeavesNoByHashEntry verifies the lazy-creation +// invariant: a vote whose signer has no weight in any credited epoch is kept in +// byKey (for later back-fill) but creates no byHash entry, so headers() never +// observes an empty per-epoch map. +func TestPushVote_ZeroWeightLeavesNoByHashEntry(t *testing.T) { + rng := utils.TestRng() + keyA := types.GenSecretKey(rng) + keyZ := types.GenSecretKey(rng) // in neither epoch + + ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) + ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{keyA.Public(): 1}) + + lane := keyA.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + + bv := newBlockVotes() + require.False(t, bv.pushVote([]*types.Epoch{ep0, ep1}, types.Sign(keyZ, types.NewLaneVote(header)))) + require.Contains(t, bv.byKey, keyZ.Public(), "vote retained in byKey for later back-fill") + require.NotContains(t, bv.byHash, header.Hash(), "zero-weight vote must not create a byHash entry") +} + // TestPushVote_AccumulatesPerEpoch verifies that a vote is credited only to the // epochs under which its signer has weight, each tracked in its own laneVoteSet. // A next-epoch-only signer contributes to the next epoch's set but leaves the From ea9db9f62af7b0edc7d4bffae6e19766fc81c87c Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 19:14:22 -0700 Subject: [PATCH 18/98] feat(autobahn): block at epoch boundary until next committee is seeded Add Registry.WaitForEpoch, backed by a highestEpoch AtomicSend bumped in makeEpoch, so a node whose CommitQC stream outran its own block execution can wait for the next epoch's committee to be seeded instead of hard-erroring. PushCommitQC resolves the next trio before taking the inner lock: a plain TrioAt fast path (almost always already seeded), falling back to WaitForEpoch + retry only on a miss. The wait holds no lock, so execution keeps seeding and wakes it; only CommitQC ingest pauses meanwhile. This makes the boundary correct for full nodes and catching-up nodes, not just actively-voting ones, so the consensus midpoint gate is no longer needed and is removed. Also graceful-drop stale PushAppVote/PushCommitQC out-of-window roads with an INFO log, and return the resolved epoch from fullCommitQC to drop a redundant lookup in the Run pusher. Co-Authored-By: Claude Opus 4.8 --- .../internal/autobahn/avail/state.go | 85 +++++++++++++------ .../internal/autobahn/avail/state_test.go | 2 +- .../internal/autobahn/consensus/inner.go | 22 ++--- .../internal/autobahn/epoch/registry.go | 37 +++++++- 4 files changed, 101 insertions(+), 45 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 040bc4d994..6794354038 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -298,28 +298,57 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // A road outside the window can only be below it (in-order processing via // waitForCommitQC bounds how far ahead idx can be), i.e. a QC we already // committed, so it is not needed — skip it like the stale case below. - ep, err := s.epochTrio.Load().EpochForRoad(idx) + trio := s.epochTrio.Load() + ep, err := trio.EpochForRoad(idx) if err != nil { + logger.Info("dropping stale CommitQC: road outside epoch window", + slog.Uint64("road", uint64(idx)), "err", err) return nil } if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } + + // If this QC closes the current epoch, resolve the next trio before taking + // the inner lock. Almost always epoch N+2 (the incoming trio's Next) is + // already seeded by the time the boundary QC arrives, so the plain TrioAt + // fast path succeeds. Only if it hasn't been seeded — a node whose CommitQC + // stream has outrun its own block execution (which seeds epochs via + // AdvanceIfNeeded) — do we block until execution catches up, then retry. + // + // While blocked in WaitForEpoch, the ONLY thing paused is CommitQC ingest: + // this goroutine (storeCommitQC, or the CommitQC stream reader on a catch-up + // node) stops, and the next PushCommitQC queues behind it on waitForCommitQC. + // No lock is held, and every other goroutine keeps running — crucially the + // avail pusher (fullCommitQC -> data.PushQC), data persistence, and + // runExecute. Execution processes the epoch-N roads already below N.Last + // (whose CommitQCs were ingested before this one), seeds N+2, and wakes us. + // So the wait is bounded by execution, never deadlocked on it. + var nextTrio *types.EpochTrio + if idx == trio.Current.RoadRange().Last { + reg := s.data.Registry() + nt, err := reg.TrioAt(idx + 1) + if err != nil { + if err := reg.WaitForEpoch(ctx, trio.Current.EpochIndex()+2); err != nil { + return err + } + nt, err = reg.TrioAt(idx + 1) + if err != nil { + return fmt.Errorf("TrioAt(%d): %w", idx+1, err) + } + } + nextTrio = &nt + } + for inner, ctrl := range s.inner.Lock() { if idx != inner.commitQCs.next { return nil } - // At an epoch boundary, rotate the lane set to the next epoch. - // N+2 is seeded by executeBlock (AdvanceIfNeeded) on every block in epoch N, - // so it is present well before the boundary CommitQC arrives. A miss here - // is a registry bug — fail loudly rather than continuing with stale state. - if idx == s.epochTrio.Load().Current.RoadRange().Last { - nextTrio, err := s.data.Registry().TrioAt(idx + 1) - if err != nil { - return fmt.Errorf("TrioAt(%d): %w", idx+1, err) - } - inner.advanceEpochLanes(nextTrio) - s.epochTrio.Store(&nextTrio) + // nextTrio is non-nil iff this QC closed the current epoch (the boundary + // check above), so this branch runs once per epoch transition. + if nextTrio != nil { + inner.advanceEpochLanes(*nextTrio) + s.epochTrio.Store(nextTrio) // The trailing ctrl.Updated() below is the ONLY wake that surfaces // laneQCs already at quorum in the incoming Current (old Next) epoch: // pushVote credited those votes silently (it notifies only for the @@ -343,7 +372,14 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] idx := v.Msg().Proposal().RoadIndex() ep, err := s.epochTrio.Load().EpochForRoad(idx) if err != nil { - return fmt.Errorf("EpochForRoad(%d): %w", idx, err) + // A needed AppVote is for a road at or below the CommitQC frontier and no + // more than one epoch behind it, hence in-window. A miss here means the + // road has fallen out of the window — a stale/superseded vote a lagging + // peer is still broadcasting. Drop it rather than erroring, which would + // tear down the whole peer connection (all streams, not just AppVotes). + logger.Info("dropping stale AppVote: road outside epoch window", + slog.Uint64("road", uint64(idx)), "err", err) + return nil } committee := ep.Committee() if err := v.VerifySig(committee); err != nil { @@ -596,11 +632,14 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc return headers, nil } -func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, error) { +// fullCommitQC returns the FullCommitQC for road n along with the epoch that +// signed it, so callers can reuse the resolved epoch without a second lookup +// (which could race the epoch window and fail on the same road). +func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { // Collect the CommitQC. qc, err := s.CommitQC(ctx, n) if err != nil { - return nil, err + return nil, nil, err } // Collect the headers from the votes. var commitHeaders []*types.BlockHeader @@ -609,16 +648,16 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful // The epoch window can advance twice between CommitQC() returning and this // lookup, dropping this QC's road below the window. Its blocks are then // below the prune cursor, so treat it as pruned (the caller skips pruned QCs). - return nil, fmt.Errorf("epoch window advanced past road %d: %w", qc.Proposal().Index(), data.ErrPruned) + return nil, nil, fmt.Errorf("epoch window advanced past road %d: %w", qc.Proposal().Index(), data.ErrPruned) } for lane := range ep.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) if err != nil { - return nil, err + return nil, nil, err } commitHeaders = append(commitHeaders, headers...) } - return types.NewFullCommitQC(qc, commitHeaders), nil + return types.NewFullCommitQC(qc, commitHeaders), ep, nil } // WaitForLocalCapacity waits until the lane owned by this node has capacity for toProduce block. @@ -710,7 +749,7 @@ func (s *State) Run(ctx context.Context) error { // Task inserting FullCommitQCs and local blocks to data state. scope.SpawnNamed("s.data.PushQC", func() error { for n := types.RoadIndex(0); ; n = max(n+1, s.FirstCommitQC()) { - qc, err := s.fullCommitQC(ctx, n) + qc, ep, err := s.fullCommitQC(ctx, n) if err != nil { if errors.Is(err, data.ErrPruned) { continue @@ -718,11 +757,9 @@ func (s *State) Run(ctx context.Context) error { return err } - // Collect the blocks we have locally. - ep, err := s.epochTrio.Load().EpochForRoad(qc.QC().Proposal().Index()) - if err != nil { - return fmt.Errorf("EpochForRoad(%d): %w", qc.QC().Proposal().Index(), err) - } + // Collect the blocks we have locally, using the same epoch + // fullCommitQC already resolved for this road (no second lookup, + // so no chance of the window racing past it here). c := ep.Committee() var blocks []*types.Block for inner := range s.inner.Lock() { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 647562859c..9c3f31bbfa 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -179,7 +179,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { } t.Logf("Check that a CommitQC was successfully reconstructed.") - got, err := state.fullCommitQC(ctx, qc.Proposal().Index()) + got, _, err := state.fullCommitQC(ctx, qc.Proposal().Index()) if err != nil { return fmt.Errorf("state.fullCommitQC(): %w", err) } diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 6d683c4ed5..f9efce10cc 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -215,23 +215,11 @@ func (s *State) pushProposal(ctx context.Context, proposal *types.FullProposal) if i.View() != proposal.View() || i.TimeoutVote.IsPresent() || i.PrepareVote.IsPresent() { return nil } - // At the midpoint of epoch N, gate on epoch N+2 being seeded in the registry. - // AdvanceIfNeeded (called by executeBlock) seeds N+2 on every executed block - // in epoch N, so the gate is satisfied as soon as any block in epoch N has - // been executed. This ensures TrioAt(N.Last+1) — called at the epoch boundary - // in data and avail — always finds N+2 as Next without erroring. - // Note: this is a best-effort gate for actively-voting nodes. A node that - // catches up past the midpoint via CommitQC sync (without processing the - // midpoint proposal) bypasses the check; that is acceptable because such a - // node has already executed enough blocks to seed N+2 anyway. - if proposal.Proposal().Msg().Index() == i.epoch.RoadRange().MidPoint() { - if _, err := i.registry.EpochAt(types.RoadIndex(i.epoch.EpochIndex()+2) * epoch.EpochLength); err != nil { - logger.Error("refusing PrepareVote: epoch N+2 not yet seeded at epoch midpoint", - "road", proposal.Proposal().Msg().Index(), - "missing", i.epoch.EpochIndex()+2) - return nil - } - } + // No epoch-seeding gate is needed here: avail.PushCommitQC blocks + // (WaitForEpoch) until the next epoch's committee is seeded before it + // advances the trio at a boundary, so consensus can vote freely and a node + // whose execution lags simply stalls at the boundary rather than racing + // ahead of seeding. v := types.Sign(s.cfg.Key, types.NewPrepareVote(proposal.Proposal().Msg())) i.PrepareVote = utils.Some(v) isend.Store(i) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 4ae3b62a62..85fb90c41c 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -1,6 +1,7 @@ package epoch import ( + "context" "fmt" "time" @@ -21,6 +22,11 @@ type registryState struct { // All layers (consensus, data, avail) read from it. type Registry struct { state utils.RWMutex[*registryState] + // highestEpoch is the highest epoch index registered so far. It only ever + // increases (epochs are registered contiguously from the current operating + // point), so WaitForEpoch can block until it reaches a target index. Kept + // separate from state so the RLock read fast-path on state is preserved. + highestEpoch utils.AtomicSend[types.EpochIndex] } // NewRegistry creates a Registry with the genesis committee. @@ -36,9 +42,28 @@ func NewRegistry( latest: 0, seeding: true, }), + highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), }, nil } +// WaitForEpoch blocks until epochIdx has been registered, or ctx is done. +// Epochs are registered contiguously from the current operating point, so once +// highestEpoch reaches epochIdx, epochIdx itself is present. Used at an epoch +// boundary: a node whose CommitQC stream has outrun its own block execution +// (which seeds new epochs via AdvanceIfNeeded) waits here rather than failing, +// and unblocks once execution catches up. +// +// CALLER CONTRACT: only block execution (AdvanceIfNeeded -> makeEpoch) advances +// highestEpoch and wakes this wait, so callers MUST NOT hold any lock on that path — +// notably the avail/data inner lock — or the wake can never fire. WaitForEpoch +// itself holds no registry lock while blocked (it waits on the highestEpoch channel). +func (r *Registry) WaitForEpoch(ctx context.Context, epochIdx types.EpochIndex) error { + _, err := r.highestEpoch.Subscribe().Wait(ctx, func(highest types.EpochIndex) bool { + return highest >= epochIdx + }) + return err +} + // SealSeeding marks the end of the initialization seeding phase. After this // call, EpochAt will no longer auto-generate missing epochs; new epochs can // only be created via AdvanceIfNeeded (driven by block execution). @@ -106,6 +131,11 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type lastRoad := firstRoad + EpochLength - 1 epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Last: lastRoad}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) s.m[epochIdx] = epoch + // Wake WaitForEpoch waiters. makeEpoch runs under the write lock, so this + // Load/Store is serialized; highestEpoch only advances. + if epochIdx > r.highestEpoch.Load() { + r.highestEpoch.Store(epochIdx) + } return epoch, nil } @@ -114,9 +144,10 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type // // Seeding model: execution of any block in epoch N seeds epoch N+2 as a // placeholder (genesis committee). Epoch N+1 is seeded by executing epoch N-1 -// blocks (same rule applied one epoch earlier). The midpoint liveness gate -// (consensus/inner.go) checks that N+2 is present before voting at MidPoint(N), -// ensuring TrioAt(N.Last+1) — which needs N+2 as Next — always succeeds. +// blocks (same rule applied one epoch earlier). At the epoch boundary +// avail.PushCommitQC needs TrioAt(N.Last+1), which requires N+2 as Next; if a +// node's CommitQC stream has outrun its execution and N+2 is not yet seeded, it +// blocks on WaitForEpoch until execution seeds it here. // // TODO: real committee rotation — pass the derived committee for N+2 here once // the execution layer computes it from the last block of epoch N. From d1b3298ae91cadb5b94003411dc4416fd0fe6b40 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 19:33:08 -0700 Subject: [PATCH 19/98] fix(autobahn): gate consensus votes by epoch; mirror boundary wait in data Consensus vote handlers (PushPrepareVote/CommitVote/TimeoutVote) now resolve the committee from the authoritative innerRecv epoch and compare the vote's own View().EpochIndex against it. A vote for a non-current epoch is dropped (Debug log, since peers may stream arbitrary-epoch votes) instead of being verified against the wrong committee, which would fail and tear down the peer's consensus stream. Removes the async-myView-lag window across an epoch boundary. data.PushQC mirrors avail.PushCommitQC: resolve the next trio (TrioAt fast path, WaitForEpoch fallback) before taking the lock and before mutating nextQC, so a lookup failure can never strand nextQC past an unfinished boundary. Remove RoadRange.MidPoint, dead since the midpoint gate was dropped. Co-Authored-By: Claude Opus 4.8 --- sei-tendermint/autobahn/types/epoch.go | 3 -- .../internal/autobahn/consensus/state.go | 32 +++++++++++++++++-- .../internal/autobahn/data/state.go | 32 +++++++++++++++---- 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index 51fce553b8..3ab13df54a 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -22,9 +22,6 @@ func OpenRoadRange() RoadRange { return RoadRange{First: 0, Last: utils.Max[Road // Has reports whether idx falls within this range (inclusive on both ends). func (r RoadRange) Has(idx RoadIndex) bool { return idx >= r.First && idx <= r.Last } -// MidPoint returns the road index at the midpoint of the range. -func (r RoadRange) MidPoint() RoadIndex { return r.First + (r.Last-r.First)/2 } - // Epoch holds the complete context for a single epoch. // Retrieved from the local Registry; never transmitted on the wire. type Epoch struct { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 9e682d470d..ddb3dc860b 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -174,7 +174,19 @@ func (s *State) PushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { // PushPrepareVote processes an unverified Prepare vote message. func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { - committee := s.myView.Load().Epoch.Committee() + // Resolve the committee from the vote's own epoch. innerRecv holds the + // authoritative current epoch (rotated synchronously by pushCommitQC); a vote + // for any other epoch is stale or premature, so drop it and move on rather + // than verifying it against the wrong committee (which would fail and tear + // down the peer's consensus stream). The vote is re-delivered once this node + // is in that epoch. + i := s.innerRecv.Load() + if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epoch.EpochIndex() { + logger.Debug("dropping prepare vote for non-current epoch", + "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epoch.EpochIndex())) + return nil + } + committee := i.epoch.Committee() if err := vote.VerifySig(committee); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } @@ -186,7 +198,14 @@ func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { // PushCommitVote processes an unverified CommitVote message. func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { - committee := s.myView.Load().Epoch.Committee() + // See PushPrepareVote: drop votes for a non-current epoch instead of failing. + i := s.innerRecv.Load() + if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epoch.EpochIndex() { + logger.Debug("dropping commit vote for non-current epoch", + "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epoch.EpochIndex())) + return nil + } + committee := i.epoch.Committee() if err := vote.VerifySig(committee); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } @@ -198,7 +217,14 @@ func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { // PushTimeoutVote processes an unverified FullTimeoutVote message. func (s *State) PushTimeoutVote(vote *types.FullTimeoutVote) error { - ep := s.myView.Load().Epoch + // See PushPrepareVote: drop votes for a non-current epoch instead of failing. + i := s.innerRecv.Load() + if voteEp := vote.View().EpochIndex; voteEp != i.epoch.EpochIndex() { + logger.Debug("dropping timeout vote for non-current epoch", + "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epoch.EpochIndex())) + return nil + } + ep := i.epoch if err := vote.Verify(ep); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 7787024d30..3799b9925d 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -425,6 +425,29 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("b.Verify(): %w", err) } } + // If this QC closes the current epoch, resolve the next trio before taking + // the lock and before mutating nextQC — mirroring avail.PushCommitQC. The + // fast path is a plain TrioAt; only if N+2 hasn't been seeded yet do we block + // on WaitForEpoch (off the lock) until execution seeds it, then retry. + // Resolving it up front means a lookup failure can never leave nextQC + // advanced past a boundary we didn't finish, which would strand epochTrio at + // the old epoch (the boundary block runs only when needQC, i.e. once). + idx := qc.QC().Proposal().Index() + var nextTrio *types.EpochTrio + if needQC && idx == s.epochTrio.Load().Current.RoadRange().Last { + cur := s.epochTrio.Load().Current + nt, err := s.cfg.Registry.TrioAt(idx + 1) + if err != nil { + if err := s.cfg.Registry.WaitForEpoch(ctx, cur.EpochIndex()+2); err != nil { + return err + } + nt, err = s.cfg.Registry.TrioAt(idx + 1) + if err != nil { + return fmt.Errorf("TrioAt(%d): %w", idx+1, err) + } + } + nextTrio = &nt + } // Atomically insert QC and blocks. for inner, ctrl := range s.inner.Lock() { if needQC { @@ -432,13 +455,8 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty inner.qcs[inner.nextQC] = qc inner.nextQC += 1 } - idx := qc.QC().Proposal().Index() - if idx == s.epochTrio.Load().Current.RoadRange().Last { - nextTrio, err := s.cfg.Registry.TrioAt(idx + 1) - if err != nil { - return fmt.Errorf("TrioAt(%d): %w", idx+1, err) - } - s.epochTrio.Store(&nextTrio) + if nextTrio != nil { + s.epochTrio.Store(nextTrio) } ctrl.Updated() } From 72b82df729c50bf7af122adce92c5941a0b315c4 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 19:47:49 -0700 Subject: [PATCH 20/98] docs(autobahn): document epoch-contiguity and per-epoch credit invariants Expand WaitForEpoch's doc to explain why waiting on the single highestEpoch value is sufficient: sequential block execution registers epochs without gaps (N+1 is seeded before N+2), so highestEpoch >= target implies target is present. Document at the PushVote call site that credit intentionally re-derives weight per epoch, so the off-lock verify / in-lock credit trio mismatch is safe, with a note not to refactor it to trust the verify-time trio. Co-Authored-By: Claude Opus 4.8 --- sei-tendermint/internal/autobahn/avail/state.go | 10 ++++++++++ .../internal/autobahn/epoch/registry.go | 16 +++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 6794354038..f97f8295a6 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -580,6 +580,16 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote // in the window under which its signer has weight. Passing Current and // Next means a next-epoch validator's vote is counted for the next epoch // immediately, so no reweight is needed when the epoch advances. + // + // This trio load is separate from the one VerifyInWindow used above + // (off-lock), so a concurrent boundary advance could credit against a + // different trio than the one that verified. That is safe by construction + // and must stay so: credit re-derives weight per epoch via + // Committee.Weight(key), so a vote only ever counts toward an epoch it is + // genuinely a member of; the signature was already checked committee- + // independently; and epochTrio only moves forward, so a future-only signer + // was already rejected by VerifyInWindow. Do NOT change credit to trust + // the verify-time trio instead of re-deriving per-epoch weight. trio := s.epochTrio.Load() if q.q[h.BlockNumber()].pushVote([]*types.Epoch{trio.Current, trio.Next}, vote) { ctrl.Updated() diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 85fb90c41c..348ef8b965 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -47,11 +47,17 @@ func NewRegistry( } // WaitForEpoch blocks until epochIdx has been registered, or ctx is done. -// Epochs are registered contiguously from the current operating point, so once -// highestEpoch reaches epochIdx, epochIdx itself is present. Used at an epoch -// boundary: a node whose CommitQC stream has outrun its own block execution -// (which seeds new epochs via AdvanceIfNeeded) waits here rather than failing, -// and unblocks once execution catches up. +// +// Waiting on the single highestEpoch value is sufficient because epochs are +// registered in increasing order without gaps: seeding is driven by sequential +// block execution (AdvanceIfNeeded seeds N+2 from an epoch-N block), and +// execution cannot reach an epoch-N block without first passing every +// epoch-(N-1) block, which seeds N+1. So a higher epoch is never registered +// before a lower one, and highestEpoch >= epochIdx implies epochIdx is present. +// +// Used at an epoch boundary: a node whose CommitQC stream has outrun its own +// block execution waits here rather than failing, and unblocks once execution +// catches up. // // CALLER CONTRACT: only block execution (AdvanceIfNeeded -> makeEpoch) advances // highestEpoch and wakes this wait, so callers MUST NOT hold any lock on that path — From f761b3ca8ecaf852826cbc19cc0d6ce68faee468 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 19:59:32 -0700 Subject: [PATCH 21/98] fix(autobahn): guard markBlockPersisted against decommissioned lane markBlockPersisted runs as an async callback after disk I/O; if an epoch advance deletes the lane in between, writing nextBlockToPersist[lane] would recreate a bare, orphaned map entry the cleanup loop never reclaims. Skip when the lane is absent, mirroring PushBlock/PushVote's lane gating. Co-Authored-By: Claude Opus 4.8 --- sei-tendermint/internal/autobahn/avail/state.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index f97f8295a6..24fa867bb0 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -906,6 +906,12 @@ func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { // callers (acquires s.inner lock internally). func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { for inner, ctrl := range s.inner.Lock() { + // Skip if the lane was decommissioned by an epoch advance between the + // persist batch snapshot and this async callback: writing would recreate + // a bare, orphaned map entry the cleanup loop never reclaims. + if _, ok := inner.nextBlockToPersist[lane]; !ok { + return + } inner.nextBlockToPersist[lane] = next ctrl.Updated() } From 2ca409f325d82dfb91a8e4d02c424ff8d6029f79 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 20:10:51 -0700 Subject: [PATCH 22/98] fix(autobahn): prevent epochTrio regression in data.PushQC needQC is computed under an earlier lock, so a concurrent PushQC caller (multiple peer streams) could re-enter the boundary block with a stale needQC and store an older trio after another caller already advanced epochTrio, corrupting EvmProxy routing and EpochForRoad verification. Re-check under the insert lock that this call actually applies the QC range (nextQC == gr.First) before storing the trio, mirroring avail's commitQCs.next guard. Co-Authored-By: Claude Opus 4.8 --- sei-tendermint/internal/autobahn/data/state.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 3799b9925d..4519af4905 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -451,11 +451,18 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty // Atomically insert QC and blocks. for inner, ctrl := range s.inner.Lock() { if needQC { + // needQC was computed under an earlier lock; re-check whether this + // call is actually the one inserting the QC range. A concurrent + // caller (multiple peer streams call PushQC) may have already applied + // it, in which case the loop below no-ops — and we must NOT store the + // trio, or a stale caller could regress epochTrio to an older epoch + // after another advanced it. Mirrors avail's commitQCs.next re-check. + applied := inner.nextQC == gr.First for inner.nextQC < gr.Next { inner.qcs[inner.nextQC] = qc inner.nextQC += 1 } - if nextTrio != nil { + if applied && nextTrio != nil { s.epochTrio.Store(nextTrio) } ctrl.Updated() From 55d8c2eb72963198ac48a379901277f2c1019042 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 13 Jul 2026 20:29:32 -0700 Subject: [PATCH 23/98] fix(autobahn): gate markBlockPersisted on blocks[lane], not nextBlockToPersist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nextBlockToPersist is populated lazily by markBlockPersisted itself, so gating its update on the entry pre-existing made every lane's first write a no-op — nextBlockToPersist never advanced, collectPersistBatch re-collected from 0, and the block persister panicked ("out of sequence"). Gate on blocks[lane] instead (created per-lane in newInner, deleted only on decommission), mirroring PushBlock's lane check. Co-Authored-By: Claude Opus 4.8 --- sei-tendermint/internal/autobahn/avail/state.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 24fa867bb0..359e00f1f1 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -908,8 +908,11 @@ func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { for inner, ctrl := range s.inner.Lock() { // Skip if the lane was decommissioned by an epoch advance between the // persist batch snapshot and this async callback: writing would recreate - // a bare, orphaned map entry the cleanup loop never reclaims. - if _, ok := inner.nextBlockToPersist[lane]; !ok { + // a bare, orphaned map entry the cleanup loop never reclaims. Gate on + // blocks[lane] (created per-lane in newInner, deleted only by + // advanceEpochLanes) — not nextBlockToPersist, which is populated lazily + // by this very function, so a missing entry there is normal first-write. + if _, ok := inner.blocks[lane]; !ok { return } inner.nextBlockToPersist[lane] = next From 30422bed4effadbb72db41c3ee3b21b75b9278b7 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 12:50:09 -0700 Subject: [PATCH 24/98] feat(autobahn): replace SealSeeding with SetupInitialTrio from data WAL tip Simulate future disk-backed epoch restore by seeding a placeholder {N-2..N+1} window in data.NewState from the CommitQC WAL tip, and drop the open seeding phase. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 2 + .../internal/autobahn/consensus/inner.go | 2 + .../internal/autobahn/consensus/state.go | 7 +- .../internal/autobahn/data/state.go | 12 ++- .../internal/autobahn/epoch/registry.go | 85 +++++++++---------- .../internal/autobahn/epoch/registry_test.go | 80 ++++++----------- .../internal/p2p/giga_router_fullnode.go | 6 -- 7 files changed, 85 insertions(+), 109 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 359e00f1f1..17ddd00666 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -166,6 +166,8 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin startRoadIdx = anchor.CommitQC.Proposal().Index() } } + // Epochs for this road must already be present: data.NewState peeks its + // CommitQC WAL and calls SetupInitialTrio before avail is constructed. startTrio, err := data.Registry().TrioAt(startRoadIdx) if err != nil { return nil, fmt.Errorf("TrioAt(%d): %w", startRoadIdx, err) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index f9efce10cc..5d285f3f7b 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -123,6 +123,8 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( // persisted CommitQC must be verified against its own epoch, which differs // from the view epoch when the CommitQC is on the last road of an epoch. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) + // Epochs for this road must already be present: data.NewState peeks its + // CommitQC WAL and calls SetupInitialTrio before consensus is constructed. viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index ddb3dc860b..2a8e02f82c 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -89,12 +89,7 @@ func NewState(cfg *Config, data *data.State) (*State, error) { pers = utils.Some(p) persistedData = d } - s, err := newState(cfg, data, pers, persistedData) - if err != nil { - return nil, err - } - data.Registry().SealSeeding() - return s, nil + return newState(cfg, data, pers, persistedData) } // newState is the internal constructor exposed for tests that need to inject diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 4519af4905..c0669b9cf3 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -303,9 +303,19 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if dataFirst > cfg.Registry.FirstBlock() { inner.skipTo(dataFirst) } + // Seed a placeholder trio from the WAL tip so QC verify and TrioAt(initRoad) + // succeed. + // TODO: in the future this information will be read from disk and verified + // (snapshots / state sync); until then derive the tip from the CommitQC WAL. + loadedQCs := dataWAL.CommitQCs.ConsumeLoaded() + setupRoad := types.RoadIndex(0) + if n := len(loadedQCs); n > 0 { + setupRoad = loadedQCs[n-1].QC().Proposal().Index() + 1 + } + cfg.Registry.SetupInitialTrio(setupRoad) // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. - for _, qc := range dataWAL.CommitQCs.ConsumeLoaded() { + for _, qc := range loadedQCs { ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) if err != nil { return nil, fmt.Errorf("load QC from WAL: epoch lookup: %w", err) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 348ef8b965..0b0153e776 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -13,9 +13,8 @@ import ( const EpochLength types.RoadIndex = 108_000 type registryState struct { - m map[types.EpochIndex]*types.Epoch - latest types.EpochIndex - seeding bool // true until SealSeeding is called + m map[types.EpochIndex]*types.Epoch + latest types.EpochIndex } // Registry is the authoritative source of epoch and committee information. @@ -29,21 +28,26 @@ type Registry struct { highestEpoch utils.AtomicSend[types.EpochIndex] } -// NewRegistry creates a Registry with the genesis committee. +// NewRegistry creates a Registry with the genesis committee and seeds epoch 1 +// so TrioAt(0) succeeds on a fresh node. func NewRegistry( committee *types.Committee, firstBlock types.GlobalBlockNumber, genesisTimestamp time.Time, ) (*Registry, error) { ep := types.NewEpoch(0, types.RoadRange{First: 0, Last: EpochLength - 1}, genesisTimestamp, committee, firstBlock) - return &Registry{ + r := &Registry{ state: utils.NewRWMutex(®istryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep}, - latest: 0, - seeding: true, + m: map[types.EpochIndex]*types.Epoch{0: ep}, + latest: 0, }), highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), - }, nil + } + // Fresh start needs Current+Next for TrioAt(0). + // TODO: in the future this information will be read from disk and verified + // (snapshots / state sync); until then seed a genesis placeholder trio. + r.SetupInitialTrio(0) + return r, nil } // WaitForEpoch blocks until epochIdx has been registered, or ctx is done. @@ -70,20 +74,27 @@ func (r *Registry) WaitForEpoch(ctx context.Context, epochIdx types.EpochIndex) return err } -// SealSeeding marks the end of the initialization seeding phase. After this -// call, EpochAt will no longer auto-generate missing epochs; new epochs can -// only be created via AdvanceIfNeeded (driven by block execution). -// -// Must be called once, after all layers (data, avail, consensus) have finished -// their NewState initialization. +// SetupInitialTrio registers placeholder epochs around roadIndex's epoch N: +// N-2, N-1, N, and N+1 (clamped at 0). That covers TrioAt(roadIndex) (Current+Next) +// and leaves room for a lagging startTrio (e.g. avail prune anchor) up to two +// epochs behind the tip. Epochs already present are left unchanged. Placeholders +// reuse the genesis committee. // -// TODO: during the seeding phase, epochs are generated with the genesis -// committee as a placeholder. Once epoch state is included in snapshots, -// replace this with reconstruction from the snapshot so that restarted nodes -// use the real per-epoch committees immediately. -func (r *Registry) SealSeeding() { +// Called from data.NewState after peeking the CommitQC WAL tip. +func (r *Registry) SetupInitialTrio(roadIndex types.RoadIndex) { + n := types.EpochIndex(roadIndex / EpochLength) + first := types.EpochIndex(0) + if n >= 2 { + first = n - 2 + } + last := n + 1 for s := range r.state.Lock() { - s.seeding = false + for idx := first; idx <= last; idx++ { + if _, ok := s.m[idx]; ok { + continue + } + _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present + } } } @@ -97,36 +108,22 @@ func (r *Registry) FirstBlock() types.GlobalBlockNumber { } // EpochAt returns the epoch for the given road index. -// During the seeding phase (before SealSeeding), missing epochs are -// auto-generated with the genesis committee. After seeding, returns an error -// if the epoch has not been registered via AdvanceIfNeeded. +// Returns an error if the epoch has not been registered via SetupInitialTrio or +// AdvanceIfNeeded. func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { epochIdx := types.EpochIndex(roadIndex / EpochLength) - // Fast path: read lock covers the common post-seal case. for s := range r.state.RLock() { if ep, ok := s.m[epochIdx]; ok { return ep, nil } - if !s.seeding { - return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) - } - } - // Slow path: seeding phase with a missing epoch — create under write lock. - for s := range r.state.Lock() { - if ep, ok := s.m[epochIdx]; ok { - return ep, nil // re-check after acquiring write lock - } - ep, _ := r.makeEpoch(s, epochIdx) //nolint:errcheck // genesis always present - if ep == nil { - return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) - } - return ep, nil + return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) } panic("unreachable") } // makeEpoch constructs a new epoch at epochIdx using the genesis committee and -// inserts it into s. Caller must hold the write lock. +// inserts it into s. Caller must hold the write lock. Overwrites if present; +// callers that must not clobber should check existence first. // Note: does NOT advance s.latest. func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*types.Epoch, error) { genesis, ok := s.m[0] @@ -150,10 +147,10 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type // // Seeding model: execution of any block in epoch N seeds epoch N+2 as a // placeholder (genesis committee). Epoch N+1 is seeded by executing epoch N-1 -// blocks (same rule applied one epoch earlier). At the epoch boundary -// avail.PushCommitQC needs TrioAt(N.Last+1), which requires N+2 as Next; if a -// node's CommitQC stream has outrun its execution and N+2 is not yet seeded, it -// blocks on WaitForEpoch until execution seeds it here. +// blocks (same rule applied one epoch earlier), or by SetupInitialTrio at startup. +// At the epoch boundary avail.PushCommitQC needs TrioAt(N.Last+1), which requires +// N+2 as Next; if a node's CommitQC stream has outrun its execution and N+2 is +// not yet seeded, it blocks on WaitForEpoch until execution seeds it here. // // TODO: real committee rotation — pass the derived committee for N+2 here once // the execution layer computes it from the last block of epoch N. diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 2d643d9146..58605da598 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -56,16 +56,14 @@ func TestEpochAt_WithinGenesisEpoch(t *testing.T) { func TestEpochAt_ErrorIfNotRegistered(t *testing.T) { r, _ := makeRegistry(t) - r.SealSeeding() - _, err := r.EpochAt(EpochLength) + _, err := r.EpochAt(2 * EpochLength) if err == nil { - t.Fatal("EpochAt(EpochLength) expected error for unregistered epoch, got nil") + t.Fatal("EpochAt(2*EpochLength) expected error for unregistered epoch, got nil") } } func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { r, _ := makeRegistry(t) - r.SealSeeding() // Executing any block in epoch 0 seeds epoch 2 (N+2). r.AdvanceIfNeeded(EpochLength - 1) ep, err := r.EpochAt(2 * EpochLength) @@ -77,56 +75,24 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { } } -func TestSeeding_AutoGeneratesEpochs(t *testing.T) { +func TestSetupInitialTrio_WindowAroundTip(t *testing.T) { r, _ := makeRegistry(t) - for _, idx := range []types.EpochIndex{4, 5, 6} { + // N=5 → {3,4,5,6}. Epochs 0,1 already present from NewRegistry. + r.SetupInitialTrio(5 * EpochLength) + for _, idx := range []types.EpochIndex{3, 4, 5, 6} { ep, err := r.EpochAt(types.RoadIndex(idx) * EpochLength) if err != nil { - t.Fatalf("EpochAt(epoch %d) failed during seeding: %v", idx, err) + t.Fatalf("EpochAt(epoch %d) after SetupInitialTrio: %v", idx, err) } if ep.EpochIndex() != idx { t.Fatalf("EpochAt(epoch %d).EpochIndex() = %d, want %d", idx, ep.EpochIndex(), idx) } } -} - -func TestSeeding_DoesNotOverwriteExisting(t *testing.T) { - r, _ := makeRegistry(t) - genesis, _ := r.EpochAt(0) - after, _ := r.EpochAt(0) - if genesis != after { - t.Fatal("seeding phase overwrote existing genesis epoch") - } -} - -func TestAdvanceIfNeeded_SeedsNextNext(t *testing.T) { - r, _ := makeRegistry(t) - r.SealSeeding() - // Epoch 0 execution seeds epoch 2, not epoch 1. - r.AdvanceIfNeeded(0) - if _, err := r.EpochAt(2 * EpochLength); err != nil { - t.Fatalf("EpochAt(epoch 2) after AdvanceIfNeeded(epoch 0): %v", err) + if _, err := r.EpochAt(2 * EpochLength); err == nil { + t.Fatal("EpochAt(epoch 2) should not be present after SetupInitialTrio(5*EpochLength)") } - if _, err := r.EpochAt(EpochLength); err == nil { - t.Fatal("EpochAt(epoch 1) should not be seeded by AdvanceIfNeeded(epoch 0) after sealing") - } -} - -func TestSeeding_LatestNotAdvanced(t *testing.T) { - r, _ := makeRegistry(t) - _, _ = r.EpochAt(5 * EpochLength) - latest := r.LatestEpoch() - if latest.EpochIndex() != 0 { - t.Fatalf("LatestEpoch().EpochIndex() = %d after seeding, want 0", latest.EpochIndex()) - } -} - -func TestSealSeeding_BlocksAutoGenerate(t *testing.T) { - r, _ := makeRegistry(t) - r.SealSeeding() - _, err := r.EpochAt(5 * EpochLength) - if err == nil { - t.Fatal("EpochAt should return error after SealSeeding for unregistered epoch") + if _, err := r.EpochAt(7 * EpochLength); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present after SetupInitialTrio(5*EpochLength)") } } @@ -151,7 +117,7 @@ func TestTrioAt_GenesisEpoch(t *testing.T) { func TestTrioAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) - // During seeding, EpochAt auto-generates missing epochs when TrioAt looks them up. + r.SetupInitialTrio(2 * EpochLength) trio, err := r.TrioAt(2 * EpochLength) if err != nil { t.Fatalf("TrioAt(epoch 2) error: %v", err) @@ -168,12 +134,22 @@ func TestTrioAt_MiddleEpoch(t *testing.T) { } } -func TestTrioAt_ErrorWhenNextMissingAfterSealing(t *testing.T) { - r, _ := makeRegistry(t) - r.SealSeeding() - // epoch 0 is present but epoch 1 is not — TrioAt should error. - _, err := r.TrioAt(0) +func TestTrioAt_ErrorWhenNextMissing(t *testing.T) { + // SetupInitialTrio always seeds Next, so leave a hole by building a bare + // registry with only epoch 0 (skipping NewRegistry's SetupInitialTrio(0)). + committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + types.GenSecretKey(utils.TestRng()).Public(): 1, + })) + ep := types.NewEpoch(0, types.RoadRange{First: 0, Last: EpochLength - 1}, time.Time{}, committee, 0) + bare := &Registry{ + state: utils.NewRWMutex(®istryState{ + m: map[types.EpochIndex]*types.Epoch{0: ep}, + latest: 0, + }), + highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), + } + _, err := bare.TrioAt(0) if err == nil { - t.Fatal("TrioAt(0) expected error when Next epoch not registered after sealing, got nil") + t.Fatal("TrioAt(0) expected error when Next epoch not registered, got nil") } } diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index bbe591d7aa..e79ee6a473 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -21,12 +21,6 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gig if err != nil { return nil, err } - // Seal seeding once all layers are constructed. On a fullnode data.State is - // the only seeding consumer (no avail/consensus layer), so this is the last - // point that needs the registry to auto-generate WAL-replay epochs. On the - // validator path the equivalent seal lives in consensus.NewState, which - // constructs the final (avail) layer. - dataState.Registry().SealSeeding() logger.Info("GigaRouter initialized (fullnode)", "validators", len(cfg.ValidatorAddrs), "dial_interval", cfg.DialInterval, "inbound_fullnode_cap", cfg.MaxInboundFullnodePeers) return &gigaFullnodeRouter{ gigaRouterCommon: newGigaRouterCommon(cfg, key, dataState, giga.NewBlockSyncService(dataState)), From 289134d1ebea1f9e97fb928f3cb296ad8e6e9c7c Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 13:38:56 -0700 Subject: [PATCH 25/98] fix(autobahn): snapshot epochTrio once in data.PushQC boundary path Avoid a concurrent advance between the boundary check and WaitForEpoch target by loading the trio into one local. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/state.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index c0669b9cf3..a9d7943be0 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -444,11 +444,11 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty // the old epoch (the boundary block runs only when needQC, i.e. once). idx := qc.QC().Proposal().Index() var nextTrio *types.EpochTrio - if needQC && idx == s.epochTrio.Load().Current.RoadRange().Last { - cur := s.epochTrio.Load().Current + trio := s.epochTrio.Load() + if needQC && idx == trio.Current.RoadRange().Last { nt, err := s.cfg.Registry.TrioAt(idx + 1) if err != nil { - if err := s.cfg.Registry.WaitForEpoch(ctx, cur.EpochIndex()+2); err != nil { + if err := s.cfg.Registry.WaitForEpoch(ctx, trio.Current.EpochIndex()+2); err != nil { return err } nt, err = s.cfg.Registry.TrioAt(idx + 1) From 05e97b25db2cfa1f98df4c2f6f0efb289e246296 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 13:54:34 -0700 Subject: [PATCH 26/98] fix(autobahn): seed epochs from executed CommitQC road, not FinalAppState AdvanceIfNeeded must track tipcut progress through execution, not a lagging AppQC stamp that can stick under carry-forward. Co-authored-by: Cursor --- sei-tendermint/internal/p2p/giga_router_common.go | 15 +++++++++------ .../internal/p2p/giga_router_validator_test.go | 6 ++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index df56e611a8..9f79de529a 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -288,13 +288,16 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo if err := r.data.PushAppHash(ctx, b.GlobalNumber, resp.AppHash); err != nil { return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } - // Seed epoch N+2 in the registry. Both validators and full nodes execute - // blocks, so this is the single path that advances epoch seeding. - // When real committee rotation is implemented, the last block of epoch N will - // pass the derived committee for N+2 here instead of using the placeholder. - if appState, ok := b.FinalAppState.Get(); ok { - r.data.Registry().AdvanceIfNeeded(appState.RoadIndex()) + // Seed epoch N+2 from the executed tipcut road (not FinalAppState: that is a + // lagging AppQC stamp and can stick under carry-forward). Both validators and + // full nodes execute blocks, so this is the single path that advances epoch + // seeding. When real committee rotation is implemented, execution of epoch N + // will pass the derived committee for N+2 here instead of a placeholder. + qc, err := r.data.QC(ctx, b.GlobalNumber) + if err != nil { + return nil, fmt.Errorf("r.data.QC(%v): %w", b.GlobalNumber, err) } + r.data.Registry().AdvanceIfNeeded(qc.QC().Proposal().Index()) return commitResp, nil } diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 1ee7e80f8a..4b87dcef5f 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -12,6 +12,7 @@ import ( "golang.org/x/time/rate" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/conn" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/proxy" @@ -187,6 +188,11 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { } require.Equal(t, gb.Payload.Txs(), rbBytes, "router[0].BlockByNumber(%v).Block.Data.Txs ≠ data.GlobalBlock(%v).Payload.Txs", h, h) } + // executeBlock seeds epoch N+2 from the executed CommitQC road (AdvanceIfNeeded). + // Registry starts with only {0,1} from SetupInitialTrio(0); after any epoch-0 + // execution, epoch 2 must be present. + _, err := giga0.data.Registry().EpochAt(2 * epoch.EpochLength) + require.NoError(t, err, "executeBlock should AdvanceIfNeeded so epoch 2 is registered") return nil }) require.NoError(t, err) From f85014c5e51296e57929abb821814aa3b2a6838d Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 14:05:40 -0700 Subject: [PATCH 27/98] fix(autobahn): SetupInitialTrio from consensus tip on restart Idempotent insurance if consensus's persisted CommitQC leads the data WAL tip. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/consensus/inner.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 5d285f3f7b..e849515bdc 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -123,8 +123,12 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( // persisted CommitQC must be verified against its own epoch, which differs // from the view epoch when the CommitQC is on the last road of an epoch. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) - // Epochs for this road must already be present: data.NewState peeks its - // CommitQC WAL and calls SetupInitialTrio before consensus is constructed. + // data.NewState already SetupInitialTrio's from the data WAL tip. Consensus's + // persisted CommitQC can still lead that tip (avail advances before the async + // FullCommitQC→data.PushQC path), so seed around the consensus road as well. + // TODO: in the future this information will be read from disk and verified + // (snapshots / state sync); until then derive it from persisted CommitQC. + registry.SetupInitialTrio(nextViewRoad) viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) From 018bc0c96d3348e86a7e4d5df6dfe41e974b0175 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 14:18:08 -0700 Subject: [PATCH 28/98] docs(autobahn): clarify WaitForEpoch vs SetupInitialTrio highestEpoch gaps SetupInitialTrio can raise highestEpoch while leaving epochs below the restart tip unregistered; live WaitForEpoch callers only wait for tip+2. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/epoch/registry.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 0b0153e776..bb6bfcf86a 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -52,19 +52,18 @@ func NewRegistry( // WaitForEpoch blocks until epochIdx has been registered, or ctx is done. // -// Waiting on the single highestEpoch value is sufficient because epochs are -// registered in increasing order without gaps: seeding is driven by sequential -// block execution (AdvanceIfNeeded seeds N+2 from an epoch-N block), and -// execution cannot reach an epoch-N block without first passing every -// epoch-(N-1) block, which seeds N+1. So a higher epoch is never registered -// before a lower one, and highestEpoch >= epochIdx implies epochIdx is present. +// Waiting on the single highestEpoch value is sufficient for the live path: +// AdvanceIfNeeded registers epochs contiguously ahead of execution (N → N+2), +// and callers only wait for Current.EpochIndex()+2. SetupInitialTrio may also +// raise highestEpoch while leaving gaps below the restart tip; those gaps are +// never WaitForEpoch targets (callers do not wait for epochs behind tip). // // Used at an epoch boundary: a node whose CommitQC stream has outrun its own // block execution waits here rather than failing, and unblocks once execution // catches up. // // CALLER CONTRACT: only block execution (AdvanceIfNeeded -> makeEpoch) advances -// highestEpoch and wakes this wait, so callers MUST NOT hold any lock on that path — +// highestEpoch for live waits, so callers MUST NOT hold any lock on that path — // notably the avail/data inner lock — or the wake can never fire. WaitForEpoch // itself holds no registry lock while blocked (it waits on the highestEpoch channel). func (r *Registry) WaitForEpoch(ctx context.Context, epochIdx types.EpochIndex) error { From f319c4c72a2abcd24278b3c304099e0dadafac3d Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 14:51:06 -0700 Subject: [PATCH 29/98] refactor(autobahn): own epochTrio via AtomicSend on inner, Recv on State Replace atomic.Pointer so mutation stays under Lock on each layer's inner, while lock-free readers only get Load through AtomicRecv. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 2 + .../internal/autobahn/avail/state.go | 15 +++---- .../internal/autobahn/data/state.go | 39 +++++++++---------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index e114bc1181..7b7d9e3402 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -18,6 +18,7 @@ import ( type inner struct { latestAppQC utils.Option[*types.AppQC] latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] + epochTrio utils.AtomicSend[types.EpochTrio] // Store under Lock; State holds Recv appVotes *queue[types.GlobalBlockNumber, appVotes] commitQCs *queue[types.RoadIndex, *types.CommitQC] blocks map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] @@ -68,6 +69,7 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt i := &inner{ latestAppQC: utils.None[*types.AppQC](), latestCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + epochTrio: utils.NewAtomicSend(startEpochTrio), appVotes: newQueue[types.GlobalBlockNumber, appVotes](), commitQCs: newQueue[types.RoadIndex, *types.CommitQC](), blocks: blocks, diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 17ddd00666..fa6f9af003 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "log/slog" - "sync/atomic" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail/metrics" @@ -35,7 +34,7 @@ type State struct { key types.SecretKey data *data.State inner utils.Watch[*inner] - epochTrio atomic.Pointer[types.EpochTrio] + epochTrio utils.AtomicRecv[types.EpochTrio] // Load-only view of inner.epochTrio // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. @@ -176,14 +175,13 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin if err != nil { return nil, err } - finalTrio := startTrio if inner.commitQCs.next > startTrio.Current.RoadRange().Last { nextTrio, err := data.Registry().TrioAt(inner.commitQCs.next) if err != nil { return nil, fmt.Errorf("TrioAt(%d): %w", inner.commitQCs.next, err) } inner.advanceEpochLanes(nextTrio) - finalTrio = nextTrio + inner.epochTrio.Store(nextTrio) } // Truncate WAL entries below the prune anchor that were filtered out by @@ -206,14 +204,13 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin } } - s := &State{ + return &State{ key: key, data: data, inner: utils.NewWatch(inner), + epochTrio: inner.epochTrio.Subscribe(), persisters: pers, - } - s.epochTrio.Store(&finalTrio) - return s, nil + }, nil } func (s *State) FirstCommitQC() types.RoadIndex { @@ -350,7 +347,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // check above), so this branch runs once per epoch transition. if nextTrio != nil { inner.advanceEpochLanes(*nextTrio) - s.epochTrio.Store(nextTrio) + inner.epochTrio.Store(*nextTrio) // The trailing ctrl.Updated() below is the ONLY wake that surfaces // laneQCs already at quorum in the incoming Current (old Next) epoch: // pushVote credited those votes silently (it notifies only for the diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index a9d7943be0..3181f40b48 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "sync/atomic" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" @@ -161,6 +160,10 @@ type inner struct { // RetainHeight pruning, making this in-memory index obsolete. blockHashes map[types.BlockHeaderHash]types.GlobalBlockNumber + // epochTrio is this layer's Prev/Current/Next window. Store only under + // Watch.Lock; readers use State.epochTrio (AtomicRecv). + epochTrio utils.AtomicSend[types.EpochTrio] + // first <= nextAppProposal <= nextBlockToPersist <= nextBlock <= nextQC // // This invariant guarantees no race between pruning and persisting: @@ -277,14 +280,10 @@ func (i *inner) pruneFirst(now time.Time, m *metrics.Metrics) { // State of the chain. // Contains blocks in global order and proofs of their finality. type State struct { - cfg *Config - metrics *metrics.Metrics - inner utils.Watch[*inner] - // epochTrio is a snapshot of the registry window centered on the epoch of - // the latest CommitQC. Refreshed inside the PushQC lock whenever a CommitQC - // crosses an epoch boundary. EvmProxy reads epochTrio.Current.Committee() - // to forward user transactions to the correct validators. - epochTrio atomic.Pointer[types.EpochTrio] + cfg *Config + metrics *metrics.Metrics + inner utils.Watch[*inner] + epochTrio utils.AtomicRecv[types.EpochTrio] // Load-only view of inner.epochTrio; EvmProxy reads via EpochTrio() dataWAL *DataWAL } @@ -372,14 +371,14 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if err != nil { return nil, fmt.Errorf("init epochTrio: %w", err) } - s := &State{ - cfg: cfg, - metrics: metrics.Get(), - inner: utils.NewWatch(inner), - dataWAL: dataWAL, - } - s.epochTrio.Store(&initTrio) - return s, nil + inner.epochTrio = utils.NewAtomicSend(initTrio) + return &State{ + cfg: cfg, + metrics: metrics.Get(), + inner: utils.NewWatch(inner), + epochTrio: inner.epochTrio.Subscribe(), + dataWAL: dataWAL, + }, nil } // Registry returns the epoch registry. @@ -388,13 +387,13 @@ func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } // EpochTrio returns a snapshot of the current epoch window. Returned by value // so callers (e.g. EvmProxy) get an immutable point-in-time view; they must // call again to observe a boundary advance rather than caching the result. -func (s *State) EpochTrio() types.EpochTrio { return *s.epochTrio.Load() } +func (s *State) EpochTrio() types.EpochTrio { return s.epochTrio.Load() } // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { - // epochTrio is updated atomically inside the lock at epoch boundaries. + // inner.epochTrio is updated under the lock at epoch boundaries. // EpochForRoad searches the Prev/Current/Next window. A QC more than one // epoch behind Current has its blocks below the prune cursor and would be // a no-op even if accepted, so erroring out here is correct. @@ -473,7 +472,7 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty inner.nextQC += 1 } if applied && nextTrio != nil { - s.epochTrio.Store(nextTrio) + inner.epochTrio.Store(*nextTrio) } ctrl.Updated() } From a646f4ef62701d50a784fdc9ce113b9724ca3e71 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 15:17:16 -0700 Subject: [PATCH 30/98] docs(autobahn): fix non-current epoch vote drop recovery comment Dropped prepare/commit/timeout votes are not redelivered; lagging nodes recover via the timeout path issuing fresh votes. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/consensus/state.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 2a8e02f82c..2fc851bcaf 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -173,8 +173,11 @@ func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { // authoritative current epoch (rotated synchronously by pushCommitQC); a vote // for any other epoch is stale or premature, so drop it and move on rather // than verifying it against the wrong committee (which would fail and tear - // down the peer's consensus stream). The vote is re-delivered once this node - // is in that epoch. + // down the peer's consensus stream). There is no redelivery of the dropped + // vote: sendUpdates only re-sends when the sender's latest vote changes. A + // lagging node that misses votes around the boundary recovers via the + // timeout path (new view → fresh votes), at the cost of one added view + // timeout of latency. i := s.innerRecv.Load() if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epoch.EpochIndex() { logger.Debug("dropping prepare vote for non-current epoch", @@ -193,7 +196,8 @@ func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { // PushCommitVote processes an unverified CommitVote message. func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { - // See PushPrepareVote: drop votes for a non-current epoch instead of failing. + // See PushPrepareVote: drop non-current-epoch votes; recovery is via timeout, + // not redelivery. i := s.innerRecv.Load() if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epoch.EpochIndex() { logger.Debug("dropping commit vote for non-current epoch", @@ -212,7 +216,8 @@ func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { // PushTimeoutVote processes an unverified FullTimeoutVote message. func (s *State) PushTimeoutVote(vote *types.FullTimeoutVote) error { - // See PushPrepareVote: drop votes for a non-current epoch instead of failing. + // See PushPrepareVote: drop non-current-epoch votes; recovery is via timeout, + // not redelivery. i := s.innerRecv.Load() if voteEp := vote.View().EpochIndex; voteEp != i.epoch.EpochIndex() { logger.Debug("dropping timeout vote for non-current epoch", From 16f6558b4c93b2ddee0c8fdebf0d7ad12f6f1b8f Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 15:43:59 -0700 Subject: [PATCH 31/98] docs(autobahn): note SetupInitialTrio vs CommitQC WAL span assumption Document that EpochAt on WAL restore relies on AppQC-pruned retention keeping loaded QCs within the seeded {N-2..N+1} window. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/state.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 3181f40b48..9b9a0304d9 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -312,6 +312,11 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { setupRoad = loadedQCs[n-1].QC().Proposal().Index() + 1 } cfg.Registry.SetupInitialTrio(setupRoad) + // SetupInitialTrio seeds only {N-2..N+1} around the tip. The EpochAt loop + // below therefore assumes the loaded CommitQC WAL never spans more than ~3 + // epochs behind that tip. Today that holds because the WAL is pruned against + // the latest AppQC (lag << EpochLength); if retention ever grows, seed from + // the earliest loaded QC's epoch as well (or fail closed here). // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. for _, qc := range loadedQCs { From 8646e34e081b58a7c12cd1db8ffb71c4838cf478 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 17:08:43 -0700 Subject: [PATCH 32/98] fix(autobahn): seed registry at avail Commit tip before restart TrioAt When restored CommitQCs cross the prune trio's Current.Last, TrioAt needs N+2 as Next; SetupInitialTrio around that tip before looking up the trio. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 5 +++ .../internal/autobahn/avail/state_test.go | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index fa6f9af003..a25e126157 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -176,6 +176,11 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } if inner.commitQCs.next > startTrio.Current.RoadRange().Last { + // Tip has crossed startTrio's Current. TrioAt(next) needs Current+Next for + // that tip's epoch — e.g. tip in N+1 requires N+2. data.SetupInitialTrio + // may only have seeded around a lagging data tip (through N+1), so seed + // around the avail CommitQC tip before looking up the trio. + data.Registry().SetupInitialTrio(inner.commitQCs.next) nextTrio, err := data.Registry().TrioAt(inner.commitQCs.next) if err != nil { return nil, fmt.Errorf("TrioAt(%d): %w", inner.commitQCs.next, err) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 9c3f31bbfa..e0f5abcb82 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -855,3 +855,35 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { require.NoError(t, state.PushAppQC(appQC, commitQC), "AppQC from epoch N-1 should be accepted when registry has epoch N-1 registered") } + +// TestRestartAdvanceAcrossEpochNeedsNPlus2 covers the NewState path where +// restored CommitQCs have crossed startTrio.Current.Last: TrioAt(tip) needs +// Current+Next for the tip epoch (N+1 → needs N+2), but SetupInitialTrio(0) +// only seeded {0,1}. NewState must SetupInitialTrio(tip) before TrioAt. +func TestRestartAdvanceAcrossEpochNeedsNPlus2(t *testing.T) { + rng := utils.TestRng() + registry, _, _ := epoch.GenRegistry(rng, 3) + // GenRegistry/NewRegistry → SetupInitialTrio(0) → epochs {0,1} only. + startTrio, err := registry.TrioAt(0) + require.NoError(t, err) + tip := startTrio.Current.RoadRange().Last + 1 // first road of epoch 1 + require.Equal(t, types.RoadIndex(epoch.EpochLength), tip) + + _, err = registry.TrioAt(tip) + require.Error(t, err, "TrioAt(epoch-1 tip) must fail without epoch 2") + + // Same seeding NewState does before TrioAt(commitQCs.next). + registry.SetupInitialTrio(tip) + nextTrio, err := registry.TrioAt(tip) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(1), nextTrio.Current.EpochIndex()) + require.Equal(t, types.EpochIndex(2), nextTrio.Next.EpochIndex()) + + inner, err := newInner(startTrio, utils.None[*loadedAvailState]()) + require.NoError(t, err) + inner.advanceEpochLanes(nextTrio) + inner.epochTrio.Store(nextTrio) + got := inner.epochTrio.Load() + require.Equal(t, types.EpochIndex(1), got.Current.EpochIndex()) + require.Equal(t, types.EpochIndex(2), got.Next.EpochIndex()) +} From e2c87f213927e27f69608f67529b1ad1284670a2 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 14 Jul 2026 17:23:26 -0700 Subject: [PATCH 33/98] fix(autobahn): guard decommissioned lanes; document consensus EpochAt asymmetry Skip missing block queues when collecting for data.PushQC, and note why consensus hard-errors on missing N+1 instead of WaitForEpoch like avail/data. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 9 ++++++++- sei-tendermint/internal/autobahn/consensus/inner.go | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index a25e126157..d71c9a574e 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -778,10 +778,17 @@ func (s *State) Run(ctx context.Context) error { var blocks []*types.Block for inner := range s.inner.Lock() { for lane := range c.Lanes().All() { + // Lane may have been decommissioned if advanceEpochLanes + // raced after fullCommitQC resolved ep off-lock (latent + // until real committee rotation deletes lanes). + q, ok := inner.blocks[lane] + if !ok { + continue + } lr := qc.QC().LaneRange(lane) for n := lr.First(); n < lr.Next(); n++ { // We are not expected to have all the blocks locally - only the available ones. - if b, ok := inner.blocks[lr.Lane()].q[n]; ok { + if b, ok := q.q[n]; ok { // We don't need to check the blocks against the headers, // as bad blocks will be filtered out by PushQC anyway. blocks = append(blocks, b.Msg().Block()) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index e849515bdc..1aaf72f105 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -162,6 +162,11 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } + // N+1 must already be in the registry: SetupInitialTrio / sequential + // AdvanceIfNeeded seed N+1 before N+2, and avail/data only WaitForEpoch + // for N+2 at their CommitQC boundary. Missing N+1 here is a hard + // invariant break (unlike avail/data lagging exec on N+2), so error + // rather than stall the consensus loop. nextEp, err := i.registry.EpochAt(qc.Proposal().Index() + 1) if err != nil { logger.Error("next epoch not in registry at CommitQC boundary", From eb64d08d8a8df4ea3e6409e88b2f8d533718bd62 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 15 Jul 2026 13:59:24 -0700 Subject: [PATCH 34/98] refactor(autobahn): retain lanes across epoch advance; trim trio comments Stop deleting old lane queues on advance until lane-expiry lands, and rewrite EpochTrio-related comments to contracts/invariants instead of implementation narratives. Co-authored-by: Cursor --- sei-tendermint/AGENTS.md | 1 + sei-tendermint/autobahn/types/epoch_trio.go | 29 ++----- .../internal/autobahn/avail/block_votes.go | 58 +++---------- .../internal/autobahn/avail/inner.go | 31 ++----- .../internal/autobahn/avail/inner_test.go | 8 +- .../internal/autobahn/avail/state.go | 85 +++---------------- .../internal/autobahn/consensus/inner.go | 26 ++---- .../internal/autobahn/consensus/state.go | 18 ++-- .../internal/autobahn/data/state.go | 51 +++-------- .../internal/autobahn/epoch/registry.go | 52 +++--------- .../internal/p2p/giga_router_common.go | 7 +- .../p2p/giga_router_validator_test.go | 4 +- 12 files changed, 81 insertions(+), 289 deletions(-) diff --git a/sei-tendermint/AGENTS.md b/sei-tendermint/AGENTS.md index 855348511e..530dd84e29 100644 --- a/sei-tendermint/AGENTS.md +++ b/sei-tendermint/AGENTS.md @@ -26,6 +26,7 @@ Within sei-tendermint subdirectory Required fields must be nil-checked at the boundary (constructor and proto Decode) and trusted everywhere else — do not add defensive nil-checks in internal logic. * Avoid removing comments and logs which are not obviously obsolete. Keep the original wording, only fixing mistakes or obsolete parts. +* Prefer concise comments that state invariants, contracts, and observable behavior (plus brief correctness reasoning when non-obvious). Avoid narrating how the implementation works step-by-step. * TestRng instance should be one per test, constructed directly in the test function. In case of nested/table tests, each nested test should create its own instance. Use TestRng.Split() (before spawning) if you need to pass entropy source to a spawned goroutine to ensure deterministic entropy across the goroutines. diff --git a/sei-tendermint/autobahn/types/epoch_trio.go b/sei-tendermint/autobahn/types/epoch_trio.go index ec34ded102..99cc01abbb 100644 --- a/sei-tendermint/autobahn/types/epoch_trio.go +++ b/sei-tendermint/autobahn/types/epoch_trio.go @@ -6,18 +6,16 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// EpochTrio is a view of up to three consecutive epochs centered on Current. -// Current and Next are always present; Prev may be absent (epoch 0). -// Updated only when an AppQC advances into a new epoch. +// EpochTrio is a sliding window of up to three consecutive epochs. +// Current and Next are always set; Prev is absent only for epoch 0. type EpochTrio struct { Prev utils.Option[*Epoch] // absent if Current is epoch 0 Current *Epoch Next *Epoch } -// all returns the three epochs in priority order: Current first, then Next, then Prev. -// This ensures EpochForRoad matches the most-likely epoch first and prevents an -// open-range Prev from shadowing Current or Next. +// all is Current, Next, Prev — EpochForRoad prefers Current so an open-range +// Prev cannot shadow later epochs. func (w EpochTrio) all() [3]utils.Option[*Epoch] { return [3]utils.Option[*Epoch]{utils.Some(w.Current), utils.Some(w.Next), w.Prev} } @@ -32,7 +30,6 @@ func (w EpochTrio) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { return nil, fmt.Errorf("road %d not in window %v", roadIdx, w) } -// CurrentAndNextLanes returns the union of lanes for the current and next epochs. func (w EpochTrio) CurrentAndNextLanes() map[LaneID]struct{} { lanes := make(map[LaneID]struct{}) for _, ep := range [2]*Epoch{w.Current, w.Next} { @@ -43,23 +40,7 @@ func (w EpochTrio) CurrentAndNextLanes() map[LaneID]struct{} { return lanes } -// AllLanes returns the union of lanes across all three epochs (Prev, Current, Next). -// Used when decommissioning lanes: Prev-epoch lanes must be retained until any -// boundary QC that spans the epoch transition has been fully collected. -func (w EpochTrio) AllLanes() map[LaneID]struct{} { - lanes := make(map[LaneID]struct{}) - for _, oep := range w.all() { - if ep, ok := oep.Get(); ok { - for lane := range ep.Committee().Lanes().All() { - lanes[lane] = struct{}{} - } - } - } - return lanes -} - -// VerifyInWindow calls fn against Current and Next only, skipping Prev. -// Use for votes and blocks, which must belong to the current or upcoming epoch. +// VerifyInWindow tries fn against Current then Next (not Prev). func (w EpochTrio) VerifyInWindow(fn func(*Committee) error) (*Epoch, error) { for _, ep := range [2]*Epoch{w.Current, w.Next} { if fn(ep.Committee()) == nil { diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 7cf6555684..3abd6bbf1d 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -4,16 +4,13 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" ) -// laneVoteSet accumulates weighted votes for one block hash under a single -// epoch's committee. The epoch it belongs to is the key in blockVotes.byHash, -// so the epoch a weight is valid for is always explicit. +// laneVoteSet is weighted votes for one (block hash, epoch). type laneVoteSet struct { weight uint64 votes []*types.Signed[*types.LaneVote] } -// add records vote's weight and returns true iff the set newly reached quorum -// on this vote. It is a no-op (returns false) once quorum is already met. +// add returns true iff this vote newly reaches quorum. func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) bool { if s.weight >= quorum { return false @@ -23,25 +20,12 @@ func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneV return s.weight >= quorum } -// blockVotes tracks votes for a single (lane, block number) across the epochs -// in the current trio window. Weight is accumulated per epoch independently, -// so advancing the epoch needs no reweight: the next epoch's laneVoteSet has -// been filled in parallel as votes arrived. A vote can only reach here after -// EpochTrio.VerifyInWindow accepts it against Current or Next, so accumulating -// into exactly those two epochs is complete. +// blockVotes is per-(lane, height) vote state. Weight is tracked per epoch, so +// an epoch advance does not reweight existing sets. type blockVotes struct { - // byKey dedups votes (one per signer per block) and is the durable record - // applyEpoch replays to back-fill an epoch entering the window. Entries are - // never deleted individually — a signer weight-0 in the current window may - // have weight in a later epoch — the whole map is freed when the block - // number is pruned from the vote queue (inner.prune). + // byKey: one vote per signer; source for applyEpoch backfill. byKey map[types.PublicKey]*types.Signed[*types.LaneVote] - // byHash maps a block hash to its per-epoch vote sets. An entry exists only - // once a weighted vote has been credited (credit creates it lazily), so a - // present entry always has a non-empty set — headers() can recover the block - // header from any set's votes[0]. A vote that earns no weight in any epoch it - // is credited against (e.g. a signer valid only in a now-departed epoch) - // leaves no byHash entry; it lives only in byKey. + // byHash: per-hash, per-epoch weighted sets (lazy; present ⇒ non-empty). byHash map[types.BlockHeaderHash]map[types.EpochIndex]*laneVoteSet } @@ -52,16 +36,10 @@ func newBlockVotes() blockVotes { } } -// pushVote records vote and accumulates its weight into the laneVoteSet of -// every epoch in eps under which the signer has non-zero weight. Callers pass -// the current and next epochs; a signer present only in the next epoch counts -// toward the next epoch's set immediately, so no reweight is needed when the -// epoch advances. -// -// Returns true iff the current epoch (eps[0]) newly reached its lane quorum on -// this vote — the signal for the caller to wake WaitForLaneQCs, which reads the -// current epoch's set. A quorum that forms only in the next epoch's set is -// silent; it is picked up when the boundary advance wakes waiters. +// pushVote credits vote into each eps[i] where the signer has weight. +// Contract: eps[0] is Current, later entries are Next (and only those). +// Returns true iff Current newly hit lane quorum (wake WaitForLaneQCs). +// Next-only quorum is silent until the boundary advance wakes waiters. func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.LaneVote]) bool { k := vote.Key() if _, ok := bv.byKey[k]; ok { @@ -71,7 +49,6 @@ func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.Lane notify := false for i, ep := range eps { - // Notify only for the current epoch (eps[0]). if bv.credit(ep, vote) && i == 0 { notify = true } @@ -79,11 +56,7 @@ func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.Lane return notify } -// credit adds vote to ep's set for the vote's block hash and returns whether -// ep's set newly reached quorum. A no-op for signers with no weight in ep. The -// byHash entry (and its per-epoch set) is created lazily here, only when a -// weighted vote is actually credited — a vote with no weight in any epoch it is -// credited against leaves no byHash entry, so a present entry is never empty. +// credit adds vote under ep; returns whether ep's set newly reached quorum. func (bv blockVotes) credit(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { c := ep.Committee() w := c.Weight(vote.Key()) @@ -104,13 +77,8 @@ func (bv blockVotes) credit(ep *types.Epoch, vote *types.Signed[*types.LaneVote] return set.add(w, c.LaneQuorum(), vote) } -// applyEpoch credits every stored vote to ep's set. It is called when ep newly -// enters the trio window as the Next epoch: a vote is only ever credited to the -// Current and Next epochs at push time, so a block whose votes arrived before -// ep was in range — yet finalizes under ep (e.g. a lagging lane, or ep sharing -// the prior epoch's committee) — would otherwise have an empty set under ep and -// never reach quorum. ep's sets are empty beforehand (ep was neither Current -// nor Next until now), so back-filling from byKey does not double-count. +// applyEpoch backfills ep from byKey when ep newly enters the window as Next. +// ep's sets are empty beforehand, so this does not double-count. func (bv blockVotes) applyEpoch(ep *types.Epoch) { for _, vote := range bv.byKey { bv.credit(ep, vote) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 7b7d9e3402..84b412ade8 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -153,11 +153,7 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt return i, nil } -// laneQC returns a LaneQC for (lane, n) under ep — the committee, quorum, and -// per-epoch vote set all resolved from ep. Callers pass the epoch the proposal -// is being built for; using ep (not the cached trio's Current) keeps the lookup -// consistent with the lanes WaitForLaneQCs iterates, which can differ from -// Current across an epoch boundary. +// laneQC returns a LaneQC for (lane, n) under ep's committee and vote set. func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, ep *types.Epoch) (*types.LaneQC, bool) { c := ep.Committee() quorum := c.LaneQuorum() @@ -170,14 +166,10 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, ep *types.Epoch) return nil, false } -// advanceEpochLanes rotates the lane set for a crossing into nextTrio: it -// creates queues for lanes newly introduced by the next epoch, drops lanes no -// longer in the window, and back-fills the newly-entering Next epoch's vote -// sets. pushVote credits each vote to the Current and Next epochs only, so when -// nextTrio.Next first enters the window its sets are empty; applyEpoch seeds -// them from stored votes so a block that finalizes under it (a lagging lane, or -// an identical committee) still reaches quorum. The prior Current/Next sets -// were already filled by pushVote, so they need no adjustment. +// advanceEpochLanes ensures Current∪Next lanes exist and backfills Next's +// vote sets from stored signatures (pushVote only credits Current+Next at push time). +// +// TODO(lane-expiry): do not delete old lanes here until epoch-scoped lane IDs exist. func (i *inner) advanceEpochLanes(nextTrio types.EpochTrio) { activeLanes := nextTrio.CurrentAndNextLanes() for lane := range activeLanes { @@ -194,19 +186,6 @@ func (i *inner) advanceEpochLanes(nextTrio types.EpochTrio) { i.persistedBlockStart[lane] = 0 } } - // Retain Prev-epoch lanes in the deletion guard: fullCommitQC may still be - // collecting headers from the boundary QC that triggered this advance, and - // it accesses lane queues by epoch N's committee. Prev lanes are cleaned up - // naturally on the next epoch advance when they fall outside AllLanes(). - retainLanes := nextTrio.AllLanes() - for lane := range i.blocks { - if _, ok := retainLanes[lane]; !ok { - delete(i.blocks, lane) - delete(i.votes, lane) - delete(i.nextBlockToPersist, lane) - delete(i.persistedBlockStart, lane) - } - } // Seed the newly-entering Next epoch's vote sets from votes already stored. for _, voteQueue := range i.votes { for n := voteQueue.first; n < voteQueue.next; n++ { diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index fb66f68c26..5a4e092c5a 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -840,7 +840,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { require.Equal(t, types.RoadIndex(3), i.commitQCs.next) } -func TestAdvanceEpochLanes_AddsAndRemovesLanes(t *testing.T) { +func TestAdvanceEpochLanes_AddsLanesKeepsOld(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) trio := utils.OrPanic1(registry.TrioAt(0)) @@ -853,7 +853,8 @@ func TestAdvanceEpochLanes_AddsAndRemovesLanes(t *testing.T) { require.Contains(t, i.blocks, lane, "lane %v missing after newInner", lane) } - // Add a bogus extra lane directly. + // Add a lane not in the trio — until lane-expiry lands, advance must not + // delete it (ever-growing map). var realLane types.LaneID for l := range trio.Current.Committee().Lanes().All() { realLane = l @@ -866,9 +867,8 @@ func TestAdvanceEpochLanes_AddsAndRemovesLanes(t *testing.T) { i.nextBlockToPersist[bogusLane] = 0 i.persistedBlockStart[bogusLane] = 0 - // advanceEpochLanes removes the bogus lane and keeps the real one. i.advanceEpochLanes(trio) - require.NotContains(t, i.blocks, bogusLane, "decommissioned lane still present") + require.Contains(t, i.blocks, bogusLane, "old lanes must be retained until lane-expiry") require.Contains(t, i.blocks, realLane, "active lane removed incorrectly") } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index d71c9a574e..b8f353b1ad 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -176,10 +176,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } if inner.commitQCs.next > startTrio.Current.RoadRange().Last { - // Tip has crossed startTrio's Current. TrioAt(next) needs Current+Next for - // that tip's epoch — e.g. tip in N+1 requires N+2. data.SetupInitialTrio - // may only have seeded around a lagging data tip (through N+1), so seed - // around the avail CommitQC tip before looking up the trio. + // Tip past prune trio's Current: seed registry for tip then advance. data.Registry().SetupInitialTrio(inner.commitQCs.next) nextTrio, err := data.Registry().TrioAt(inner.commitQCs.next) if err != nil { @@ -295,13 +292,8 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - // Resolve the epoch that signed this QC. EpochForRoad searches the whole - // Prev/Current/Next window because the cached trio can lag by one across a - // boundary: a QC for the new epoch may arrive while Current is still the old - // one, or land in Prev just after a rotation — both verify correctly here. - // A road outside the window can only be below it (in-order processing via - // waitForCommitQC bounds how far ahead idx can be), i.e. a QC we already - // committed, so it is not needed — skip it like the stale case below. + // Verify against the trio window (may lag one epoch at a boundary). + // Out-of-window ⇒ already committed / below tip ⇒ drop. trio := s.epochTrio.Load() ep, err := trio.EpochForRoad(idx) if err != nil { @@ -313,21 +305,8 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return fmt.Errorf("qc.Verify(): %w", err) } - // If this QC closes the current epoch, resolve the next trio before taking - // the inner lock. Almost always epoch N+2 (the incoming trio's Next) is - // already seeded by the time the boundary QC arrives, so the plain TrioAt - // fast path succeeds. Only if it hasn't been seeded — a node whose CommitQC - // stream has outrun its own block execution (which seeds epochs via - // AdvanceIfNeeded) — do we block until execution catches up, then retry. - // - // While blocked in WaitForEpoch, the ONLY thing paused is CommitQC ingest: - // this goroutine (storeCommitQC, or the CommitQC stream reader on a catch-up - // node) stops, and the next PushCommitQC queues behind it on waitForCommitQC. - // No lock is held, and every other goroutine keeps running — crucially the - // avail pusher (fullCommitQC -> data.PushQC), data persistence, and - // runExecute. Execution processes the epoch-N roads already below N.Last - // (whose CommitQCs were ingested before this one), seeds N+2, and wakes us. - // So the wait is bounded by execution, never deadlocked on it. + // Boundary: resolve next trio off-lock (TrioAt, else WaitForEpoch(N+2)). + // Must not hold inner while waiting — execution seeds N+2 under that path. var nextTrio *types.EpochTrio if idx == trio.Current.RoadRange().Last { reg := s.data.Registry() @@ -348,23 +327,14 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { if idx != inner.commitQCs.next { return nil } - // nextTrio is non-nil iff this QC closed the current epoch (the boundary - // check above), so this branch runs once per epoch transition. if nextTrio != nil { inner.advanceEpochLanes(*nextTrio) inner.epochTrio.Store(*nextTrio) - // The trailing ctrl.Updated() below is the ONLY wake that surfaces - // laneQCs already at quorum in the incoming Current (old Next) epoch: - // pushVote credited those votes silently (it notifies only for the - // then-current epoch), so WaitForLaneQCs must be woken here to re-scan - // laneQC under the just-stored trio. Do not make it conditional or the - // boundary stalls. + // Always wake: next-epoch lane quorum may already be silent in pushVote. } inner.commitQCs.pushBack(qc) metrics.ObserveCommitQC(qc) - // The persist goroutine publishes latestCommitQC after writing to disk - // (or immediately for no-op persisters), so consensus won't advance - // until the CommitQC is durable. + // latestCommitQC advances only after durable persist (or no-op persister). ctrl.Updated() return nil } @@ -376,11 +346,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] idx := v.Msg().Proposal().RoadIndex() ep, err := s.epochTrio.Load().EpochForRoad(idx) if err != nil { - // A needed AppVote is for a road at or below the CommitQC frontier and no - // more than one epoch behind it, hence in-window. A miss here means the - // road has fallen out of the window — a stale/superseded vote a lagging - // peer is still broadcasting. Drop it rather than erroring, which would - // tear down the whole peer connection (all streams, not just AppVotes). + // Out-of-window ⇒ stale; drop (do not tear down the peer). logger.Info("dropping stale AppVote: road outside epoch window", slog.Uint64("road", uint64(idx)), "err", err) return nil @@ -580,20 +546,8 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - // pushVote accumulates weight per epoch: a vote counts toward every epoch - // in the window under which its signer has weight. Passing Current and - // Next means a next-epoch validator's vote is counted for the next epoch - // immediately, so no reweight is needed when the epoch advances. - // - // This trio load is separate from the one VerifyInWindow used above - // (off-lock), so a concurrent boundary advance could credit against a - // different trio than the one that verified. That is safe by construction - // and must stay so: credit re-derives weight per epoch via - // Committee.Weight(key), so a vote only ever counts toward an epoch it is - // genuinely a member of; the signature was already checked committee- - // independently; and epochTrio only moves forward, so a future-only signer - // was already rejected by VerifyInWindow. Do NOT change credit to trust - // the verify-time trio instead of re-deriving per-epoch weight. + // Credit under a fresh trio load. Safe if the window advanced since + // VerifyInWindow: weight is re-derived per epoch; trio only moves forward. trio := s.epochTrio.Load() if q.q[h.BlockNumber()].pushVote([]*types.Epoch{trio.Current, trio.Next}, vote) { ctrl.Updated() @@ -646,9 +600,7 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc return headers, nil } -// fullCommitQC returns the FullCommitQC for road n along with the epoch that -// signed it, so callers can reuse the resolved epoch without a second lookup -// (which could race the epoch window and fail on the same road). +// fullCommitQC returns the FullCommitQC for road n and the signing epoch. func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { // Collect the CommitQC. qc, err := s.CommitQC(ctx, n) @@ -659,9 +611,7 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful var commitHeaders []*types.BlockHeader ep, err := s.epochTrio.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { - // The epoch window can advance twice between CommitQC() returning and this - // lookup, dropping this QC's road below the window. Its blocks are then - // below the prune cursor, so treat it as pruned (the caller skips pruned QCs). + // Window raced past this road ⇒ treat as pruned for the PushQC loop. return nil, nil, fmt.Errorf("epoch window advanced past road %d: %w", qc.Proposal().Index(), data.ErrPruned) } for lane := range ep.Committee().Lanes().All() { @@ -778,9 +728,7 @@ func (s *State) Run(ctx context.Context) error { var blocks []*types.Block for inner := range s.inner.Lock() { for lane := range c.Lanes().All() { - // Lane may have been decommissioned if advanceEpochLanes - // raced after fullCommitQC resolved ep off-lock (latent - // until real committee rotation deletes lanes). + // Missing lane queue ⇒ skip (not yet created / future expiry). q, ok := inner.blocks[lane] if !ok { continue @@ -917,12 +865,7 @@ func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { // callers (acquires s.inner lock internally). func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { for inner, ctrl := range s.inner.Lock() { - // Skip if the lane was decommissioned by an epoch advance between the - // persist batch snapshot and this async callback: writing would recreate - // a bare, orphaned map entry the cleanup loop never reclaims. Gate on - // blocks[lane] (created per-lane in newInner, deleted only by - // advanceEpochLanes) — not nextBlockToPersist, which is populated lazily - // by this very function, so a missing entry there is normal first-write. + // Skip if blocks[lane] is gone; nextBlockToPersist may be lazily empty. if _, ok := inner.blocks[lane]; !ok { return } diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 1aaf72f105..13b7fe0b0b 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -118,16 +118,9 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( persisted = *decoded } - // The runtime epoch is the current view's epoch (NextIndexOpt(CommitQC)): - // View() must stamp the next view with the epoch it belongs to. But the - // persisted CommitQC must be verified against its own epoch, which differs - // from the view epoch when the CommitQC is on the last road of an epoch. + // View epoch = tipcut road; CommitQC may still be the prior epoch's last road. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) - // data.NewState already SetupInitialTrio's from the data WAL tip. Consensus's - // persisted CommitQC can still lead that tip (avail advances before the async - // FullCommitQC→data.PushQC path), so seed around the consensus road as well. - // TODO: in the future this information will be read from disk and verified - // (snapshots / state sync); until then derive it from persisted CommitQC. + // Seed around consensus tip (may lead data WAL tip). TODO: verified snapshot. registry.SetupInitialTrio(nextViewRoad) viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { @@ -162,11 +155,8 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } - // N+1 must already be in the registry: SetupInitialTrio / sequential - // AdvanceIfNeeded seed N+1 before N+2, and avail/data only WaitForEpoch - // for N+2 at their CommitQC boundary. Missing N+1 here is a hard - // invariant break (unlike avail/data lagging exec on N+2), so error - // rather than stall the consensus loop. + // Invariant: N+1 is registered before this CommitQC (setup / AdvanceIfNeeded). + // Hard-error if missing — do not WaitForEpoch like avail/data do for N+2. nextEp, err := i.registry.EpochAt(qc.Proposal().Index() + 1) if err != nil { logger.Error("next epoch not in registry at CommitQC boundary", @@ -199,8 +189,7 @@ func (s *State) pushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { if qc.View().Less(i.View()) { return nil } - // TimeoutQC advances view number; clear votes and prepareQC (stale view). - // Epoch is unchanged: the road index did not advance, so i.epoch carries over. + // TimeoutQC advances view number; clear votes and prepareQC. Epoch unchanged. isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, registry: i.registry, epoch: i.epoch}) } return nil @@ -226,11 +215,6 @@ func (s *State) pushProposal(ctx context.Context, proposal *types.FullProposal) if i.View() != proposal.View() || i.TimeoutVote.IsPresent() || i.PrepareVote.IsPresent() { return nil } - // No epoch-seeding gate is needed here: avail.PushCommitQC blocks - // (WaitForEpoch) until the next epoch's committee is seeded before it - // advances the trio at a boundary, so consensus can vote freely and a node - // whose execution lags simply stalls at the boundary rather than racing - // ahead of seeding. v := types.Sign(s.cfg.Key, types.NewPrepareVote(proposal.Proposal().Msg())) i.PrepareVote = utils.Some(v) isend.Store(i) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 2fc851bcaf..f0626af373 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -169,15 +169,9 @@ func (s *State) PushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { // PushPrepareVote processes an unverified Prepare vote message. func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { - // Resolve the committee from the vote's own epoch. innerRecv holds the - // authoritative current epoch (rotated synchronously by pushCommitQC); a vote - // for any other epoch is stale or premature, so drop it and move on rather - // than verifying it against the wrong committee (which would fail and tear - // down the peer's consensus stream). There is no redelivery of the dropped - // vote: sendUpdates only re-sends when the sender's latest vote changes. A - // lagging node that misses votes around the boundary recovers via the - // timeout path (new view → fresh votes), at the cost of one added view - // timeout of latency. + // Contract: accept only Current-epoch votes (innerRecv). Others drop without + // error (avoid wrong-committee verify / peer teardown). No redelivery — + // lagging peers recover via view timeout. i := s.innerRecv.Load() if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epoch.EpochIndex() { logger.Debug("dropping prepare vote for non-current epoch", @@ -196,8 +190,7 @@ func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { // PushCommitVote processes an unverified CommitVote message. func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { - // See PushPrepareVote: drop non-current-epoch votes; recovery is via timeout, - // not redelivery. + // Same Current-epoch contract as PushPrepareVote. i := s.innerRecv.Load() if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epoch.EpochIndex() { logger.Debug("dropping commit vote for non-current epoch", @@ -216,8 +209,7 @@ func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { // PushTimeoutVote processes an unverified FullTimeoutVote message. func (s *State) PushTimeoutVote(vote *types.FullTimeoutVote) error { - // See PushPrepareVote: drop non-current-epoch votes; recovery is via timeout, - // not redelivery. + // Same Current-epoch contract as PushPrepareVote. i := s.innerRecv.Load() if voteEp := vote.View().EpochIndex; voteEp != i.epoch.EpochIndex() { logger.Debug("dropping timeout vote for non-current epoch", diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 9b9a0304d9..38c5fa92f1 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -160,8 +160,7 @@ type inner struct { // RetainHeight pruning, making this in-memory index obsolete. blockHashes map[types.BlockHeaderHash]types.GlobalBlockNumber - // epochTrio is this layer's Prev/Current/Next window. Store only under - // Watch.Lock; readers use State.epochTrio (AtomicRecv). + // Store under Watch.Lock; readers use State.epochTrio. epochTrio utils.AtomicSend[types.EpochTrio] // first <= nextAppProposal <= nextBlockToPersist <= nextBlock <= nextQC @@ -302,21 +301,15 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if dataFirst > cfg.Registry.FirstBlock() { inner.skipTo(dataFirst) } - // Seed a placeholder trio from the WAL tip so QC verify and TrioAt(initRoad) - // succeed. - // TODO: in the future this information will be read from disk and verified - // (snapshots / state sync); until then derive the tip from the CommitQC WAL. + // Seed {N-2..N+1} around the WAL tip for QC verify / TrioAt. + // Invariant: CommitQC WAL span behind that tip is within this window + // (pruned vs latest AppQC today). TODO: verified snapshot / state-sync. loadedQCs := dataWAL.CommitQCs.ConsumeLoaded() setupRoad := types.RoadIndex(0) if n := len(loadedQCs); n > 0 { setupRoad = loadedQCs[n-1].QC().Proposal().Index() + 1 } cfg.Registry.SetupInitialTrio(setupRoad) - // SetupInitialTrio seeds only {N-2..N+1} around the tip. The EpochAt loop - // below therefore assumes the loaded CommitQC WAL never spans more than ~3 - // epochs behind that tip. Today that holds because the WAL is pruned against - // the latest AppQC (lag << EpochLength); if retention ever grows, seed from - // the earliest loaded QC's epoch as well (or fail closed here). // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. for _, qc := range loadedQCs { @@ -365,10 +358,7 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { initRoad := types.RoadIndex(0) if inner.nextQC > 0 { if lastQC := inner.qcs[inner.nextQC-1]; lastQC != nil { - // Use Index+1 to mirror the live PushQC boundary-advance: when the last - // committed QC is the final road of epoch N, TrioAt(N.Last) returns - // Current=ep(N), but TrioAt(N.Last+1) correctly returns Current=ep(N+1), - // matching the epochTrio the live path stored before the crash. + // Tipcut road (Index+1): matches live PushQC after a boundary store. initRoad = lastQC.QC().Proposal().Index() + 1 } } @@ -389,19 +379,14 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { // Registry returns the epoch registry. func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } -// EpochTrio returns a snapshot of the current epoch window. Returned by value -// so callers (e.g. EvmProxy) get an immutable point-in-time view; they must -// call again to observe a boundary advance rather than caching the result. +// EpochTrio is a point-in-time snapshot; re-call after a boundary advance. func (s *State) EpochTrio() types.EpochTrio { return s.epochTrio.Load() } // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { - // inner.epochTrio is updated under the lock at epoch boundaries. - // EpochForRoad searches the Prev/Current/Next window. A QC more than one - // epoch behind Current has its blocks below the prune cursor and would be - // a no-op even if accepted, so erroring out here is correct. + // Out-of-window ⇒ below prune tip; reject. ep, err := s.epochTrio.Load().EpochForRoad(qc.QC().Proposal().Index()) if err != nil { return err @@ -427,10 +412,7 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("qc.Verify(): %w", err) } } - // blocks is the subset of blocks finalized by this QC, so they all belong - // to the same epoch as the QC. Using ep here is intentional and asymmetric - // with PushBlock, which resolves the committee from the stored QC at each - // block's position rather than from the incoming QC. + // Blocks share the QC's epoch (unlike PushBlock, which uses the stored QC). byHash := map[types.BlockHeaderHash]*types.Block{} committee := ep.Committee() for _, b := range blocks { @@ -439,13 +421,8 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("b.Verify(): %w", err) } } - // If this QC closes the current epoch, resolve the next trio before taking - // the lock and before mutating nextQC — mirroring avail.PushCommitQC. The - // fast path is a plain TrioAt; only if N+2 hasn't been seeded yet do we block - // on WaitForEpoch (off the lock) until execution seeds it, then retry. - // Resolving it up front means a lookup failure can never leave nextQC - // advanced past a boundary we didn't finish, which would strand epochTrio at - // the old epoch (the boundary block runs only when needQC, i.e. once). + // Boundary: resolve next trio off-lock before mutating nextQC + // (TrioAt, else WaitForEpoch(N+2)), so a failed wait cannot strand the tip. idx := qc.QC().Proposal().Index() var nextTrio *types.EpochTrio trio := s.epochTrio.Load() @@ -465,12 +442,8 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty // Atomically insert QC and blocks. for inner, ctrl := range s.inner.Lock() { if needQC { - // needQC was computed under an earlier lock; re-check whether this - // call is actually the one inserting the QC range. A concurrent - // caller (multiple peer streams call PushQC) may have already applied - // it, in which case the loop below no-ops — and we must NOT store the - // trio, or a stale caller could regress epochTrio to an older epoch - // after another advanced it. Mirrors avail's commitQCs.next re-check. + // Re-check under lock: only the inserter may store nextTrio + // (stale concurrent PushQC must not regress the window). applied := inner.nextQC == gr.First for inner.nextQC < gr.Next { inner.qcs[inner.nextQC] = qc diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index bb6bfcf86a..8c697b903b 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -21,10 +21,8 @@ type registryState struct { // All layers (consensus, data, avail) read from it. type Registry struct { state utils.RWMutex[*registryState] - // highestEpoch is the highest epoch index registered so far. It only ever - // increases (epochs are registered contiguously from the current operating - // point), so WaitForEpoch can block until it reaches a target index. Kept - // separate from state so the RLock read fast-path on state is preserved. + // highestEpoch is a monotonic high-water mark for WaitForEpoch. + // Kept off registryState so EpochAt can stay on the RLock fast path. highestEpoch utils.AtomicSend[types.EpochIndex] } @@ -50,22 +48,12 @@ func NewRegistry( return r, nil } -// WaitForEpoch blocks until epochIdx has been registered, or ctx is done. +// WaitForEpoch blocks until epochIdx is registered, or ctx is done. // -// Waiting on the single highestEpoch value is sufficient for the live path: -// AdvanceIfNeeded registers epochs contiguously ahead of execution (N → N+2), -// and callers only wait for Current.EpochIndex()+2. SetupInitialTrio may also -// raise highestEpoch while leaving gaps below the restart tip; those gaps are -// never WaitForEpoch targets (callers do not wait for epochs behind tip). -// -// Used at an epoch boundary: a node whose CommitQC stream has outrun its own -// block execution waits here rather than failing, and unblocks once execution -// catches up. -// -// CALLER CONTRACT: only block execution (AdvanceIfNeeded -> makeEpoch) advances -// highestEpoch for live waits, so callers MUST NOT hold any lock on that path — -// notably the avail/data inner lock — or the wake can never fire. WaitForEpoch -// itself holds no registry lock while blocked (it waits on the highestEpoch channel). +// Contract: live waiters only target tip+2 (AdvanceIfNeeded seeds contiguously +// ahead of execution). SetupInitialTrio may leave gaps below the restart tip; +// those are not WaitForEpoch targets. Callers must not hold the avail/data +// inner lock (execution advances highestEpoch under that path). func (r *Registry) WaitForEpoch(ctx context.Context, epochIdx types.EpochIndex) error { _, err := r.highestEpoch.Subscribe().Wait(ctx, func(highest types.EpochIndex) bool { return highest >= epochIdx @@ -73,13 +61,9 @@ func (r *Registry) WaitForEpoch(ctx context.Context, epochIdx types.EpochIndex) return err } -// SetupInitialTrio registers placeholder epochs around roadIndex's epoch N: -// N-2, N-1, N, and N+1 (clamped at 0). That covers TrioAt(roadIndex) (Current+Next) -// and leaves room for a lagging startTrio (e.g. avail prune anchor) up to two -// epochs behind the tip. Epochs already present are left unchanged. Placeholders -// reuse the genesis committee. -// -// Called from data.NewState after peeking the CommitQC WAL tip. +// SetupInitialTrio registers placeholder epochs {N-2..N+1} around roadIndex +// (clamped at 0) with the genesis committee. Idempotent for existing entries. +// TODO: replace with verified snapshot / state-sync epoch info. func (r *Registry) SetupInitialTrio(roadIndex types.RoadIndex) { n := types.EpochIndex(roadIndex / EpochLength) first := types.EpochIndex(0) @@ -141,18 +125,10 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type return epoch, nil } -// AdvanceIfNeeded seeds epoch N+2 when a block in epoch N is executed. -// Called by executeBlock (giga_router_common.go) on both validator and full-node paths. -// -// Seeding model: execution of any block in epoch N seeds epoch N+2 as a -// placeholder (genesis committee). Epoch N+1 is seeded by executing epoch N-1 -// blocks (same rule applied one epoch earlier), or by SetupInitialTrio at startup. -// At the epoch boundary avail.PushCommitQC needs TrioAt(N.Last+1), which requires -// N+2 as Next; if a node's CommitQC stream has outrun its execution and N+2 is -// not yet seeded, it blocks on WaitForEpoch until execution seeds it here. -// -// TODO: real committee rotation — pass the derived committee for N+2 here once -// the execution layer computes it from the last block of epoch N. +// AdvanceIfNeeded seeds epoch N+2 when any road in epoch N is executed. +// Invariant: by the time Tipcut needs TrioAt(N.Last+1), N+2 is either already +// registered or waiters use WaitForEpoch(N+2) until this runs. +// TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { nextNextIdx := types.EpochIndex(roadIndex/EpochLength) + 2 // Fast path: epoch already seeded (common after the first block of the epoch). diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 9f79de529a..eb74fe65e9 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -288,11 +288,8 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo if err := r.data.PushAppHash(ctx, b.GlobalNumber, resp.AppHash); err != nil { return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } - // Seed epoch N+2 from the executed tipcut road (not FinalAppState: that is a - // lagging AppQC stamp and can stick under carry-forward). Both validators and - // full nodes execute blocks, so this is the single path that advances epoch - // seeding. When real committee rotation is implemented, execution of epoch N - // will pass the derived committee for N+2 here instead of a placeholder. + // Seed N+2 from the executed CommitQC tipcut (not lagging FinalAppState). + // TODO: real N+2 committee once execution derives it. qc, err := r.data.QC(ctx, b.GlobalNumber) if err != nil { return nil, fmt.Errorf("r.data.QC(%v): %w", b.GlobalNumber, err) diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 4b87dcef5f..ef7d538395 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -188,9 +188,7 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { } require.Equal(t, gb.Payload.Txs(), rbBytes, "router[0].BlockByNumber(%v).Block.Data.Txs ≠ data.GlobalBlock(%v).Payload.Txs", h, h) } - // executeBlock seeds epoch N+2 from the executed CommitQC road (AdvanceIfNeeded). - // Registry starts with only {0,1} from SetupInitialTrio(0); after any epoch-0 - // execution, epoch 2 must be present. + // executeBlock → AdvanceIfNeeded must seed epoch 2 after epoch-0 execution. _, err := giga0.data.Registry().EpochAt(2 * epoch.EpochLength) require.NoError(t, err, "executeBlock should AdvanceIfNeeded so epoch 2 is registered") return nil From a2827c2465e9993b70dd0fabaedbb336c50f8752 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 15 Jul 2026 15:23:49 -0700 Subject: [PATCH 35/98] refactor(autobahn): store LaneQC in blockVotes on quorum Construct and cache LaneQC when a vote set crosses quorum; pushVote returns Option of the newly formed Current QC so wake policy stays at the caller. Co-authored-by: Cursor --- .../internal/autobahn/avail/block_votes.go | 51 ++++++++---- .../autobahn/avail/block_votes_test.go | 79 ++++++++----------- .../internal/autobahn/avail/inner.go | 14 +--- .../internal/autobahn/avail/state.go | 4 +- 4 files changed, 72 insertions(+), 76 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 3abd6bbf1d..771da202b1 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -2,22 +2,29 @@ package avail import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) // laneVoteSet is weighted votes for one (block hash, epoch). +// qc is set once when weight first reaches quorum. type laneVoteSet struct { weight uint64 votes []*types.Signed[*types.LaneVote] + qc *types.LaneQC } -// add returns true iff this vote newly reaches quorum. -func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) bool { - if s.weight >= quorum { - return false +// add credits vote. Returns the newly formed LaneQC iff this vote crosses quorum. +func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { + if s.qc != nil { + return utils.None[*types.LaneQC]() } s.weight += weight s.votes = append(s.votes, vote) - return s.weight >= quorum + if s.weight < quorum { + return utils.None[*types.LaneQC]() + } + s.qc = types.NewLaneQC(s.votes) + return utils.Some(s.qc) } // blockVotes is per-(lane, height) vote state. Weight is tracked per epoch, so @@ -38,30 +45,31 @@ func newBlockVotes() blockVotes { // pushVote credits vote into each eps[i] where the signer has weight. // Contract: eps[0] is Current, later entries are Next (and only those). -// Returns true iff Current newly hit lane quorum (wake WaitForLaneQCs). -// Next-only quorum is silent until the boundary advance wakes waiters. -func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.LaneVote]) bool { +// Stores a LaneQC on any epoch that newly reaches quorum, but returns only the +// newly formed Current QC. Caller notifies when present. +func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { k := vote.Key() if _, ok := bv.byKey[k]; ok { - return false + return utils.None[*types.LaneQC]() } bv.byKey[k] = vote - notify := false + current := utils.None[*types.LaneQC]() for i, ep := range eps { - if bv.credit(ep, vote) && i == 0 { - notify = true + formed := bv.credit(ep, vote) + if i == 0 { + current = formed } } - return notify + return current } -// credit adds vote under ep; returns whether ep's set newly reached quorum. -func (bv blockVotes) credit(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { +// credit adds vote under ep; returns a newly formed LaneQC if this vote crosses quorum. +func (bv blockVotes) credit(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { c := ep.Committee() w := c.Weight(vote.Key()) if w == 0 { - return false + return utils.None[*types.LaneQC]() } h := vote.Msg().Header().Hash() byEpoch, ok := bv.byHash[h] @@ -84,3 +92,14 @@ func (bv blockVotes) applyEpoch(ep *types.Epoch) { bv.credit(ep, vote) } } + +// laneQC returns a stored LaneQC for ep, if any hash has one. +func (bv blockVotes) laneQC(ep *types.Epoch) utils.Option[*types.LaneQC] { + epIdx := ep.EpochIndex() + for _, byEpoch := range bv.byHash { + if set, ok := byEpoch[epIdx]; ok && set.qc != nil { + return utils.Some(set.qc) + } + } + return utils.None[*types.LaneQC]() +} diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index f3b53e563a..892604512e 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -16,9 +16,6 @@ func makeVoteEpoch(idx types.EpochIndex, weights map[types.PublicKey]uint64) *ty return types.NewEpoch(idx, rr, time.Time{}, c, 0) } -// TestLaneVoteSet_Add exercises the weight accumulation and quorum edge of the -// laneVoteSet primitive in isolation: it accumulates until quorum, reports the -// crossing exactly once, and is a no-op afterwards. func TestLaneVoteSet_Add(t *testing.T) { rng := utils.TestRng() lane := types.GenSecretKey(rng).Public() @@ -28,30 +25,28 @@ func TestLaneVoteSet_Add(t *testing.T) { } set := &laneVoteSet{} - // Below quorum (2): accumulates, returns false. - require.False(t, set.add(1, 2, mkVote())) + require.False(t, set.add(1, 2, mkVote()).IsPresent()) require.Equal(t, uint64(1), set.weight) require.Len(t, set.votes, 1) - // Crosses quorum: returns true exactly on the crossing. - require.True(t, set.add(1, 2, mkVote())) + require.Nil(t, set.qc) + + qc, ok := set.add(1, 2, mkVote()).Get() + require.True(t, ok) + require.Equal(t, set.qc, qc) require.Equal(t, uint64(2), set.weight) require.Len(t, set.votes, 2) - // Already at quorum: no-op, returns false, does not append. - require.False(t, set.add(1, 2, mkVote())) + + require.False(t, set.add(1, 2, mkVote()).IsPresent()) require.Equal(t, uint64(2), set.weight) require.Len(t, set.votes, 2) - // A single heavy vote can cross quorum from empty in one step. heavy := &laneVoteSet{} - require.True(t, heavy.add(3, 2, mkVote())) + require.True(t, heavy.add(3, 2, mkVote()).IsPresent()) require.Equal(t, uint64(3), heavy.weight) require.Len(t, heavy.votes, 1) + require.NotNil(t, heavy.qc) } -// TestPushVote_ZeroWeightLeavesNoByHashEntry verifies the lazy-creation -// invariant: a vote whose signer has no weight in any credited epoch is kept in -// byKey (for later back-fill) but creates no byHash entry, so headers() never -// observes an empty per-epoch map. func TestPushVote_ZeroWeightLeavesNoByHashEntry(t *testing.T) { rng := utils.TestRng() keyA := types.GenSecretKey(rng) @@ -64,15 +59,11 @@ func TestPushVote_ZeroWeightLeavesNoByHashEntry(t *testing.T) { header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() bv := newBlockVotes() - require.False(t, bv.pushVote([]*types.Epoch{ep0, ep1}, types.Sign(keyZ, types.NewLaneVote(header)))) + require.False(t, bv.pushVote([]*types.Epoch{ep0, ep1}, types.Sign(keyZ, types.NewLaneVote(header))).IsPresent()) require.Contains(t, bv.byKey, keyZ.Public(), "vote retained in byKey for later back-fill") require.NotContains(t, bv.byHash, header.Hash(), "zero-weight vote must not create a byHash entry") } -// TestPushVote_AccumulatesPerEpoch verifies that a vote is credited only to the -// epochs under which its signer has weight, each tracked in its own laneVoteSet. -// A next-epoch-only signer contributes to the next epoch's set but leaves the -// current epoch's set untouched, so no reweight is needed at the boundary. func TestPushVote_AccumulatesPerEpoch(t *testing.T) { rng := utils.TestRng() @@ -90,11 +81,9 @@ func TestPushVote_AccumulatesPerEpoch(t *testing.T) { bv := newBlockVotes() - // E votes first: weight 0 in ep0, 1 in ep1 (ep1 LaneQuorum == 1). E reaches - // the next epoch's quorum but NOT the current epoch's, so pushVote reports - // nothing — WaitForLaneQCs only cares about the current epoch. - require.False(t, bv.pushVote(eps, types.Sign(keyE, types.NewLaneVote(header))), - "a next-epoch-only quorum must not notify") + // E reaches Next quorum only: stored on set, but return is None (Current-only). + require.False(t, bv.pushVote(eps, types.Sign(keyE, types.NewLaneVote(header))).IsPresent(), + "Next-only quorum must not return a QC") require.Contains(t, bv.byKey, keyE.Public()) byEpoch := bv.byHash[h] @@ -102,24 +91,29 @@ func TestPushVote_AccumulatesPerEpoch(t *testing.T) { require.False(t, inEp0, "E has no weight in ep0; the ep0 set must not be created") set1 := byEpoch[1] require.NotNil(t, set1) - require.Len(t, set1.votes, 1, "E's vote is credited to ep1 only") + require.NotNil(t, set1.qc, "Next still stores its LaneQC") + require.Len(t, set1.votes, 1) require.Equal(t, keyE.Public(), set1.votes[0].Key()) - // The block header is recoverable from any set's votes[0] (no dedicated field). require.Equal(t, header, set1.votes[0].Msg().Header()) - // A votes: weight 1 in ep0, 0 in ep1. Reaches the ep0 quorum → notifies. - require.True(t, bv.pushVote(eps, types.Sign(keyA, types.NewLaneVote(header)))) + // A reaches Current quorum ⇒ returned for notify. + currentQC, ok := bv.pushVote(eps, types.Sign(keyA, types.NewLaneVote(header))).Get() + require.True(t, ok) set0 := byEpoch[0] require.NotNil(t, set0) - require.Len(t, set0.votes, 1, "only A's vote is in the ep0 set") + require.Equal(t, currentQC, set0.qc) + require.Len(t, set0.votes, 1) require.Equal(t, keyA.Public(), set0.votes[0].Key()) require.Len(t, set1.votes, 1, "A's vote does not leak into the ep1 set") + + qc, ok := bv.laneQC(ep0).Get() + require.True(t, ok) + require.Equal(t, currentQC, qc) + qc, ok = bv.laneQC(ep1).Get() + require.True(t, ok) + require.Equal(t, set1.qc, qc) } -// TestApplyEpoch_BackfillsFromStoredVotes verifies that when an epoch newly -// enters the window, its set is seeded from votes that arrived before it was in -// range. This covers a block voted while Current=N but finalized under N+2 with -// an identical committee: without back-fill its N+2 set would be empty. func TestApplyEpoch_BackfillsFromStoredVotes(t *testing.T) { rng := utils.TestRng() keyA := types.GenSecretKey(rng) @@ -129,7 +123,6 @@ func TestApplyEpoch_BackfillsFromStoredVotes(t *testing.T) { weights := map[types.PublicKey]uint64{ keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, } - // ep0=current, ep1=next at push time; ep2 shares the same committee. ep0 := makeVoteEpoch(0, weights) ep1 := makeVoteEpoch(1, weights) ep2 := makeVoteEpoch(2, weights) @@ -140,39 +133,32 @@ func TestApplyEpoch_BackfillsFromStoredVotes(t *testing.T) { h := header.Hash() bv := newBlockVotes() - // A votes while the window is {ep0, ep1}: credited to ep0 and ep1 only. bv.pushVote([]*types.Epoch{ep0, ep1}, types.Sign(keyA, types.NewLaneVote(header))) _, hasEp2 := bv.byHash[h][2] require.False(t, hasEp2, "ep2 not credited at push time") - // ep2 enters the window as Next → back-fill from stored votes. bv.applyEpoch(ep2) set2 := bv.byHash[h][2] require.NotNil(t, set2, "ep2 set seeded on entry") require.Len(t, set2.votes, 1) require.Equal(t, keyA.Public(), set2.votes[0].Key()) require.Equal(t, uint64(1), set2.weight) + require.Nil(t, set2.qc, "quorum is 2; backfill of one vote must not form a QC") - // Idempotency guard within a single entry: a later signer arriving via - // pushVote (window now {ep1, ep2}) adds to ep2 without disturbing A's credit. bv.pushVote([]*types.Epoch{ep1, ep2}, types.Sign(keyB, types.NewLaneVote(header))) require.Len(t, set2.votes, 2, "B added to ep2 after entry") + require.NotNil(t, set2.qc) } -// TestPushVote_DedupsSigner verifies a signer's second vote for the same block -// is ignored (byKey dedup), so weight is never double-counted. func TestPushVote_DedupsSigner(t *testing.T) { rng := utils.TestRng() keyA := types.GenSecretKey(rng) - // Four validators (LaneQuorum = Faulty()+1 = 2) so a single vote is below - // quorum and the effect of a duplicate is observable. keyB := types.GenSecretKey(rng) keyC := types.GenSecretKey(rng) keyD := types.GenSecretKey(rng) weights := map[types.PublicKey]uint64{ keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, } - // Distinct current/next epochs (as in production) with the same committee. eps := []*types.Epoch{makeVoteEpoch(0, weights), makeVoteEpoch(1, weights)} lane := keyA.Public() @@ -182,12 +168,11 @@ func TestPushVote_DedupsSigner(t *testing.T) { bv := newBlockVotes() vote := types.Sign(keyA, types.NewLaneVote(header)) - require.False(t, bv.pushVote(eps, vote), "one of four validators is below quorum (2)") + require.False(t, bv.pushVote(eps, vote).IsPresent(), "one of four validators is below quorum (2)") set := bv.byHash[header.Hash()][0] require.Equal(t, uint64(1), set.weight) - // Re-pushing A's vote must not add weight again. - require.False(t, bv.pushVote(eps, vote)) + require.False(t, bv.pushVote(eps, vote).IsPresent()) require.Equal(t, uint64(1), set.weight, "duplicate vote must not double-count") require.Len(t, set.votes, 1) } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 84b412ade8..d7be44327a 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -153,17 +153,9 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt return i, nil } -// laneQC returns a LaneQC for (lane, n) under ep's committee and vote set. -func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, ep *types.Epoch) (*types.LaneQC, bool) { - c := ep.Committee() - quorum := c.LaneQuorum() - epIdx := ep.EpochIndex() - for _, byEpoch := range i.votes[lane].q[n].byHash { - if set, ok := byEpoch[epIdx]; ok && set.weight >= quorum { - return types.NewLaneQC(set.votes), true - } - } - return nil, false +// laneQC returns a stored LaneQC for (lane, n) under ep, if any. +func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, ep *types.Epoch) utils.Option[*types.LaneQC] { + return i.votes[lane].q[n].laneQC(ep) } // advanceEpochLanes ensures Current∪Next lanes exist and backfills Next's diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index b8f353b1ad..fdc957d620 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -549,7 +549,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote // Credit under a fresh trio load. Safe if the window advanced since // VerifyInWindow: weight is re-derived per epoch; trio only moves forward. trio := s.epochTrio.Load() - if q.q[h.BlockNumber()].pushVote([]*types.Epoch{trio.Current, trio.Next}, vote) { + if q.q[h.BlockNumber()].pushVote([]*types.Epoch{trio.Current, trio.Next}, vote).IsPresent() { ctrl.Updated() } } @@ -648,7 +648,7 @@ func (s *State) WaitForLaneQCs( for lane := range ep.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { - if qc, ok := inner.laneQC(lane, first+i, ep); ok { + if qc, ok := inner.laneQC(lane, first+i, ep).Get(); ok { laneQCs[lane] = qc } else { break From 7f22c11a921603f5be6b6cef9cf4b63f9907c2c9 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 15 Jul 2026 16:09:01 -0700 Subject: [PATCH 36/98] fix(autobahn): seed avail restart trio from CommitQC tip Use max(last loaded CommitQC tipcut, prune-anchor tipcut) for the operating window; drop prune-anchor trio for WAL truncate lanes; keep appVotes floored by the prune anchor when the tip is ahead. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 26 ++++++- .../internal/autobahn/avail/inner_test.go | 42 +++++++++++ .../internal/autobahn/avail/state.go | 37 +++------- .../internal/autobahn/avail/state_test.go | 69 +++++++++++++++---- 4 files changed, 129 insertions(+), 45 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index d7be44327a..be8011ca7c 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -57,6 +57,23 @@ type loadedAvailState struct { blocks map[types.LaneID][]persist.LoadedBlock } +// commitTipRoad is the tipcut road after restore (commitQCs.next): one past the +// last loaded CommitQC, floored by the prune-anchor tipcut when the WAL lags. +func commitTipRoad(loaded utils.Option[*loadedAvailState]) types.RoadIndex { + tip := types.RoadIndex(0) + ls, ok := loaded.Get() + if !ok { + return tip + } + if n := len(ls.commitQCs); n > 0 { + tip = ls.commitQCs[n-1].Index + 1 + } + if anchor, ok := ls.pruneAnchor.Get(); ok { + tip = max(tip, anchor.CommitQC.Proposal().Index()+1) + } + return tip +} + func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailState]) (*inner, error) { lanes := startEpochTrio.CurrentAndNextLanes() votes := map[types.LaneID]*queue[types.BlockNumber, blockVotes]{} @@ -77,16 +94,17 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt nextBlockToPersist: make(map[types.LaneID]types.BlockNumber, len(votes)), persistedBlockStart: make(map[types.LaneID]types.BlockNumber, len(votes)), } - i.appVotes.prune(startEpochTrio.Current.FirstBlock()) - l, ok := loaded.Get() if !ok { + // Fresh node: appVotes start at the operating epoch's first block. + i.appVotes.prune(startEpochTrio.Current.FirstBlock()) return i, nil } // Apply the persisted prune anchor first: prune() positions all queues // (commitQCs, blocks, votes) so that subsequent pushBack calls insert // at the correct indices without needing reset(). + // prune also sets appVotes.first from the anchor CommitQC. if anchor, ok := l.pruneAnchor.Get(); ok { logger.Info("loaded persisted prune anchor", slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), @@ -98,6 +116,10 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt for lane := range i.blocks { i.persistedBlockStart[lane] = anchor.CommitQC.LaneRange(lane).First() } + } else if startEpochTrio.Current.EpochIndex() == 0 { + // No anchor: don't raise appVotes to a tip epoch's FirstBlock — live + // advanceEpochLanes also leaves appVotes at the genesis floor. + i.appVotes.prune(startEpochTrio.Current.FirstBlock()) } // Restore persisted CommitQCs. prune() may have already pushed the diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 5a4e092c5a..6f00d9bdf8 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -840,6 +840,48 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { require.Equal(t, types.RoadIndex(3), i.commitQCs.next) } +// TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock covers tip-based restart: +// appVotes must be floored by the prune-anchor CommitQC, not tip Current.FirstBlock +// (queue.prune only advances; a too-high bootstrap would stick). +func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 4) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + qcs := make([]*types.CommitQC, 3) + prev := utils.None[*types.CommitQC]() + for i := range qcs { + qcs[i] = makeCommitQC(ep0, keys, prev, nil, utils.None[*types.AppQC]()) + prev = utils.Some(qcs[i]) + } + wantAppFirst := qcs[1].GlobalRange().First + // Synthetic tip trio with FirstBlock above the prune floor (placeholder + // registry epochs share genesis FirstBlock, so inflate for this invariant). + tipFirst := wantAppFirst + 1000 + c := ep0.Committee() + tipCurrent := types.NewEpoch(1, types.RoadRange{First: epoch.EpochLength, Last: 2*epoch.EpochLength - 1}, ep0.FirstTimestamp(), c, tipFirst) + tipNext := types.NewEpoch(2, types.RoadRange{First: 2 * epoch.EpochLength, Last: 3*epoch.EpochLength - 1}, ep0.FirstTimestamp(), c, tipFirst) + tipTrio := types.EpochTrio{Prev: utils.Some(ep0), Current: tipCurrent, Next: tipNext} + + ap := types.NewAppProposal(wantAppFirst, qcs[1].Index(), types.GenAppHash(rng), 0) + loaded := &loadedAvailState{ + pruneAnchor: utils.Some(&PruneAnchor{ + AppQC: types.NewAppQC(makeAppVotes(keys, ap)), + CommitQC: qcs[1], + }), + commitQCs: []persist.LoadedCommitQC{ + {Index: 1, QC: qcs[1]}, + {Index: 2, QC: qcs[2]}, + }, + } + + inner, err := newInner(tipTrio, utils.Some(loaded)) + require.NoError(t, err) + require.Equal(t, wantAppFirst, inner.appVotes.first, + "appVotes must follow prune-anchor GlobalRange, not tip Current.FirstBlock") + require.NotEqual(t, tipFirst, inner.appVotes.first) +} + func TestAdvanceEpochLanes_AddsLanesKeepsOld(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index fdc957d620..ec03775107 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -157,45 +157,24 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } - // TODO: in production a node should always restart from a snapshot rather than - // genesis; starting from road 0 is only correct on the very first boot. - startRoadIdx := types.RoadIndex(0) - if ls, ok := loaded.Get(); ok { - if anchor, ok := ls.pruneAnchor.Get(); ok { - startRoadIdx = anchor.CommitQC.Proposal().Index() - } - } - // Epochs for this road must already be present: data.NewState peeks its - // CommitQC WAL and calls SetupInitialTrio before avail is constructed. - startTrio, err := data.Registry().TrioAt(startRoadIdx) + // Operating trio is the CommitQC tipcut (not the prune-anchor road). + // Tip may lead data.SetupInitialTrio; seed around it before TrioAt. + commitTip := commitTipRoad(loaded) + data.Registry().SetupInitialTrio(commitTip) + startTrio, err := data.Registry().TrioAt(commitTip) if err != nil { - return nil, fmt.Errorf("TrioAt(%d): %w", startRoadIdx, err) + return nil, fmt.Errorf("TrioAt(%d): %w", commitTip, err) } inner, err := newInner(startTrio, loaded) if err != nil { return nil, err } - if inner.commitQCs.next > startTrio.Current.RoadRange().Last { - // Tip past prune trio's Current: seed registry for tip then advance. - data.Registry().SetupInitialTrio(inner.commitQCs.next) - nextTrio, err := data.Registry().TrioAt(inner.commitQCs.next) - if err != nil { - return nil, fmt.Errorf("TrioAt(%d): %w", inner.commitQCs.next, err) - } - inner.advanceEpochLanes(nextTrio) - inner.epochTrio.Store(nextTrio) - } // Truncate WAL entries below the prune anchor that were filtered out by - // loadPersistedState. + // loadPersistedState. Lanes come from the operating (tip) trio. if ls, ok := loaded.Get(); ok { if anchor, ok := ls.pruneAnchor.Get(); ok { - anchorTrio, err := data.Registry().TrioAt(anchor.CommitQC.Proposal().Index()) - if err != nil { - return nil, fmt.Errorf("TrioAt(%d): %w", anchor.CommitQC.Proposal().Index(), err) - } - lanes := anchorTrio.CurrentAndNextLanes() - for lane := range lanes { + for lane := range startTrio.CurrentAndNextLanes() { if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { return nil, fmt.Errorf("prune stale block WAL entries: %w", err) } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index e0f5abcb82..2731c2f6ee 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -856,34 +856,75 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { "AppQC from epoch N-1 should be accepted when registry has epoch N-1 registered") } -// TestRestartAdvanceAcrossEpochNeedsNPlus2 covers the NewState path where -// restored CommitQCs have crossed startTrio.Current.Last: TrioAt(tip) needs -// Current+Next for the tip epoch (N+1 → needs N+2), but SetupInitialTrio(0) -// only seeded {0,1}. NewState must SetupInitialTrio(tip) before TrioAt. -func TestRestartAdvanceAcrossEpochNeedsNPlus2(t *testing.T) { +// TestRestartTrioFromCommitTipNeedsNPlus2 covers NewState seeding: TrioAt(tip) +// for tip in epoch N+1 needs N+2, but SetupInitialTrio(0) only seeded {0,1}. +func TestRestartTrioFromCommitTipNeedsNPlus2(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 3) // GenRegistry/NewRegistry → SetupInitialTrio(0) → epochs {0,1} only. - startTrio, err := registry.TrioAt(0) + ep0, err := registry.TrioAt(0) require.NoError(t, err) - tip := startTrio.Current.RoadRange().Last + 1 // first road of epoch 1 + tip := ep0.Current.RoadRange().Last + 1 // first road of epoch 1 require.Equal(t, types.RoadIndex(epoch.EpochLength), tip) _, err = registry.TrioAt(tip) require.Error(t, err, "TrioAt(epoch-1 tip) must fail without epoch 2") - // Same seeding NewState does before TrioAt(commitQCs.next). + // Same seeding NewState does before TrioAt(commit tip). registry.SetupInitialTrio(tip) - nextTrio, err := registry.TrioAt(tip) + tipTrio, err := registry.TrioAt(tip) require.NoError(t, err) - require.Equal(t, types.EpochIndex(1), nextTrio.Current.EpochIndex()) - require.Equal(t, types.EpochIndex(2), nextTrio.Next.EpochIndex()) + require.Equal(t, types.EpochIndex(1), tipTrio.Current.EpochIndex()) + require.Equal(t, types.EpochIndex(2), tipTrio.Next.EpochIndex()) - inner, err := newInner(startTrio, utils.None[*loadedAvailState]()) + inner, err := newInner(tipTrio, utils.None[*loadedAvailState]()) require.NoError(t, err) - inner.advanceEpochLanes(nextTrio) - inner.epochTrio.Store(nextTrio) got := inner.epochTrio.Load() require.Equal(t, types.EpochIndex(1), got.Current.EpochIndex()) require.Equal(t, types.EpochIndex(2), got.Next.EpochIndex()) } + +func TestCommitTipRoad(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 4) + require.Equal(t, types.RoadIndex(0), commitTipRoad(utils.None[*loadedAvailState]())) + + qcs := make([]*types.CommitQC, 10) + prev := utils.None[*types.CommitQC]() + for i := range qcs { + qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + prev = utils.Some(qcs[i]) + } + require.Equal(t, types.RoadIndex(3), commitTipRoad(utils.Some(&loadedAvailState{ + commitQCs: []persist.LoadedCommitQC{ + {Index: 0, QC: qcs[0]}, + {Index: 1, QC: qcs[1]}, + {Index: 2, QC: qcs[2]}, + }, + }))) + + // Loaded tip ahead of prune anchor → tip from last QC. + gr1 := qcs[1].GlobalRange() + appQC1 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal(gr1.First, qcs[1].Index(), types.GenAppHash(rng), 0))) + require.Equal(t, types.RoadIndex(3), commitTipRoad(utils.Some(&loadedAvailState{ + pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC1, CommitQC: qcs[1]}), + commitQCs: []persist.LoadedCommitQC{ + {Index: 1, QC: qcs[1]}, + {Index: 2, QC: qcs[2]}, + }, + }))) + + // WAL empty / lagging behind prune anchor → tip from max(., anchor+1). + gr9 := qcs[9].GlobalRange() + appQC9 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal(gr9.First, qcs[9].Index(), types.GenAppHash(rng), 0))) + require.Equal(t, types.RoadIndex(10), commitTipRoad(utils.Some(&loadedAvailState{ + pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC9, CommitQC: qcs[9]}), + }))) + require.Equal(t, types.RoadIndex(10), commitTipRoad(utils.Some(&loadedAvailState{ + pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC9, CommitQC: qcs[9]}), + commitQCs: []persist.LoadedCommitQC{ + {Index: 0, QC: qcs[0]}, + {Index: 1, QC: qcs[1]}, + }, + })), "stale WAL below anchor must not win over anchor tipcut") +} From 3c0ad6e86fa2d786e661f3008a2575eaa2a18a42 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 15 Jul 2026 16:15:59 -0700 Subject: [PATCH 37/98] fix(autobahn): accept CommitQC only for Current or Next Drop roads before Current; panic above Next (impossible after waitForCommitQC in-order tip). Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_trio.go | 14 +++++++ .../autobahn/types/epoch_trio_test.go | 42 +++++++++++++++++++ .../internal/autobahn/avail/state.go | 6 +-- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_trio.go b/sei-tendermint/autobahn/types/epoch_trio.go index 99cc01abbb..f16e0ff662 100644 --- a/sei-tendermint/autobahn/types/epoch_trio.go +++ b/sei-tendermint/autobahn/types/epoch_trio.go @@ -30,6 +30,20 @@ func (w EpochTrio) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { return nil, fmt.Errorf("road %d not in window %v", roadIdx, w) } +// EpochForCommitRoad resolves the epoch for CommitQC ingest. +func (w EpochTrio) EpochForCommitRoad(roadIdx RoadIndex) (*Epoch, error) { + if w.Current.RoadRange().Has(roadIdx) { + return w.Current, nil + } + if w.Next.RoadRange().Has(roadIdx) { + return w.Next, nil + } + if roadIdx > w.Next.RoadRange().Last { + panic(fmt.Sprintf("CommitQC road %d above Next %v: impossible after waitForCommitQC in-order tip", roadIdx, w)) + } + return nil, fmt.Errorf("CommitQC road %d before Current in %v", roadIdx, w) +} + func (w EpochTrio) CurrentAndNextLanes() map[LaneID]struct{} { lanes := make(map[LaneID]struct{}) for _, ep := range [2]*Epoch{w.Current, w.Next} { diff --git a/sei-tendermint/autobahn/types/epoch_trio_test.go b/sei-tendermint/autobahn/types/epoch_trio_test.go index 62e3914395..79149bda56 100644 --- a/sei-tendermint/autobahn/types/epoch_trio_test.go +++ b/sei-tendermint/autobahn/types/epoch_trio_test.go @@ -70,6 +70,48 @@ func TestEpochForRoad_OutsideWindowReturnsError(t *testing.T) { } } +func TestEpochForCommitRoad_CurrentAndNext(t *testing.T) { + prev, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Prev: utils.Some(prev), Current: current, Next: next} + + ep, err := w.EpochForCommitRoad(150) + if err != nil { + t.Fatalf("Current: %v", err) + } + if ep.EpochIndex() != current.EpochIndex() { + t.Fatalf("got epoch %d, want current", ep.EpochIndex()) + } + ep, err = w.EpochForCommitRoad(250) + if err != nil { + t.Fatalf("Next: %v", err) + } + if ep.EpochIndex() != next.EpochIndex() { + t.Fatalf("got epoch %d, want next", ep.EpochIndex()) + } +} + +func TestEpochForCommitRoad_BeforeCurrentReturnsError(t *testing.T) { + prev, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Prev: utils.Some(prev), Current: current, Next: next} + if _, err := w.EpochForCommitRoad(50); err == nil { + t.Fatal("Prev road expected error (stale drop), got nil") + } + if _, err := w.EpochForCommitRoad(0); err == nil { + t.Fatal("road before Prev expected error, got nil") + } +} + +func TestEpochForCommitRoad_AboveNextPanics(t *testing.T) { + _, current, next, _ := makeThreeEpochs(t) + w := types.EpochTrio{Current: current, Next: next} + defer func() { + if recover() == nil { + t.Fatal("expected panic for road above Next") + } + }() + _, _ = w.EpochForCommitRoad(999) +} + func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { // Regression: genesis epoch used to have OpenRoadRange (Last=MaxUint64). // EpochForRoad iterated [Prev, Current, Next], so Prev.Has(any) was always diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index ec03775107..437be8efce 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -271,12 +271,10 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - // Verify against the trio window (may lag one epoch at a boundary). - // Out-of-window ⇒ already committed / below tip ⇒ drop. trio := s.epochTrio.Load() - ep, err := trio.EpochForRoad(idx) + ep, err := trio.EpochForCommitRoad(idx) if err != nil { - logger.Info("dropping stale CommitQC: road outside epoch window", + logger.Info("dropping stale CommitQC: road before Current", slog.Uint64("road", uint64(idx)), "err", err) return nil } From 8b2d66105abbedee1d8dc147961c082648fd8496 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 15 Jul 2026 16:33:19 -0700 Subject: [PATCH 38/98] refactor(autobahn): add Registry.WaitForTrio for boundary seeding Replace TrioAt+WaitForEpoch at avail/data epoch boundaries with WaitForTrio, and note that Wait assumes unpruned registry epochs until ErrPruned exists. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 14 ++----- .../internal/autobahn/consensus/inner.go | 2 +- .../internal/autobahn/data/state.go | 12 ++---- .../internal/autobahn/epoch/registry.go | 39 +++++++++++-------- .../internal/autobahn/epoch/registry_test.go | 29 ++++++++++++++ 5 files changed, 59 insertions(+), 37 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 437be8efce..e7e78e0823 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -282,20 +282,12 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return fmt.Errorf("qc.Verify(): %w", err) } - // Boundary: resolve next trio off-lock (TrioAt, else WaitForEpoch(N+2)). - // Must not hold inner while waiting — execution seeds N+2 under that path. + // Boundary: resolve next trio off-lock (WaitForTrio). var nextTrio *types.EpochTrio if idx == trio.Current.RoadRange().Last { - reg := s.data.Registry() - nt, err := reg.TrioAt(idx + 1) + nt, err := s.data.Registry().WaitForTrio(ctx, idx+1) if err != nil { - if err := reg.WaitForEpoch(ctx, trio.Current.EpochIndex()+2); err != nil { - return err - } - nt, err = reg.TrioAt(idx + 1) - if err != nil { - return fmt.Errorf("TrioAt(%d): %w", idx+1, err) - } + return err } nextTrio = &nt } diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 13b7fe0b0b..91cbe83963 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -156,7 +156,7 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { return nil } // Invariant: N+1 is registered before this CommitQC (setup / AdvanceIfNeeded). - // Hard-error if missing — do not WaitForEpoch like avail/data do for N+2. + // Hard-error if missing — do not WaitForTrio like avail/data do for N+2. nextEp, err := i.registry.EpochAt(qc.Proposal().Index() + 1) if err != nil { logger.Error("next epoch not in registry at CommitQC boundary", diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 38c5fa92f1..4e2efc3d85 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -422,20 +422,14 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty } } // Boundary: resolve next trio off-lock before mutating nextQC - // (TrioAt, else WaitForEpoch(N+2)), so a failed wait cannot strand the tip. + // (WaitForTrio), so a failed wait cannot strand the tip. idx := qc.QC().Proposal().Index() var nextTrio *types.EpochTrio trio := s.epochTrio.Load() if needQC && idx == trio.Current.RoadRange().Last { - nt, err := s.cfg.Registry.TrioAt(idx + 1) + nt, err := s.cfg.Registry.WaitForTrio(ctx, idx+1) if err != nil { - if err := s.cfg.Registry.WaitForEpoch(ctx, trio.Current.EpochIndex()+2); err != nil { - return err - } - nt, err = s.cfg.Registry.TrioAt(idx + 1) - if err != nil { - return fmt.Errorf("TrioAt(%d): %w", idx+1, err) - } + return err } nextTrio = &nt } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 8c697b903b..eebfa8f9c4 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -21,7 +21,7 @@ type registryState struct { // All layers (consensus, data, avail) read from it. type Registry struct { state utils.RWMutex[*registryState] - // highestEpoch is a monotonic high-water mark for WaitForEpoch. + // highestEpoch is a monotonic high-water mark for WaitForTrio. // Kept off registryState so EpochAt can stay on the RLock fast path. highestEpoch utils.AtomicSend[types.EpochIndex] } @@ -48,19 +48,6 @@ func NewRegistry( return r, nil } -// WaitForEpoch blocks until epochIdx is registered, or ctx is done. -// -// Contract: live waiters only target tip+2 (AdvanceIfNeeded seeds contiguously -// ahead of execution). SetupInitialTrio may leave gaps below the restart tip; -// those are not WaitForEpoch targets. Callers must not hold the avail/data -// inner lock (execution advances highestEpoch under that path). -func (r *Registry) WaitForEpoch(ctx context.Context, epochIdx types.EpochIndex) error { - _, err := r.highestEpoch.Subscribe().Wait(ctx, func(highest types.EpochIndex) bool { - return highest >= epochIdx - }) - return err -} - // SetupInitialTrio registers placeholder epochs {N-2..N+1} around roadIndex // (clamped at 0) with the genesis committee. Idempotent for existing entries. // TODO: replace with verified snapshot / state-sync epoch info. @@ -117,7 +104,7 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type lastRoad := firstRoad + EpochLength - 1 epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Last: lastRoad}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) s.m[epochIdx] = epoch - // Wake WaitForEpoch waiters. makeEpoch runs under the write lock, so this + // Wake WaitForTrio waiters. makeEpoch runs under the write lock, so this // Load/Store is serialized; highestEpoch only advances. if epochIdx > r.highestEpoch.Load() { r.highestEpoch.Store(epochIdx) @@ -127,7 +114,7 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type // AdvanceIfNeeded seeds epoch N+2 when any road in epoch N is executed. // Invariant: by the time Tipcut needs TrioAt(N.Last+1), N+2 is either already -// registered or waiters use WaitForEpoch(N+2) until this runs. +// registered or waiters use WaitForTrio until this runs. // TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { nextNextIdx := types.EpochIndex(roadIndex/EpochLength) + 2 @@ -147,6 +134,10 @@ func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { // TrioAt returns the EpochTrio centered on the epoch containing roadIndex. // Current and Next must already be present in the registry (callers seed them); // returns an error if either is missing. Prev is absent only when Current is epoch 0. +// +// The registry retains epochs indefinitely (no pruning). If pruning is added, +// a missing epoch below the retain window should surface as ErrPruned so +// callers can silently drop rather than Wait forever. func (r *Registry) TrioAt(roadIndex types.RoadIndex) (types.EpochTrio, error) { centerIdx := types.EpochIndex(roadIndex / EpochLength) current, err := r.EpochAt(types.RoadIndex(centerIdx) * EpochLength) @@ -165,3 +156,19 @@ func (r *Registry) TrioAt(roadIndex types.RoadIndex) (types.EpochTrio, error) { } return trio, nil } + +// WaitForTrio blocks until TrioAt(roadIndex) can succeed (Next registered), +// then returns that trio. Same retention note as TrioAt. +// Must not hold the avail/data inner lock (execution seeds via AdvanceIfNeeded). +func (r *Registry) WaitForTrio(ctx context.Context, roadIndex types.RoadIndex) (types.EpochTrio, error) { + if trio, err := r.TrioAt(roadIndex); err == nil { + return trio, nil + } + centerIdx := types.EpochIndex(roadIndex / EpochLength) + if _, err := r.highestEpoch.Subscribe().Wait(ctx, func(highest types.EpochIndex) bool { + return highest >= centerIdx+1 + }); err != nil { + return types.EpochTrio{}, err + } + return r.TrioAt(roadIndex) +} diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 58605da598..0e410d7cda 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -6,6 +6,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/stretchr/testify/require" ) func makeRegistry(t *testing.T) (*Registry, *types.Committee) { @@ -153,3 +154,31 @@ func TestTrioAt_ErrorWhenNextMissing(t *testing.T) { t.Fatal("TrioAt(0) expected error when Next epoch not registered, got nil") } } + +func TestWaitForTrio_FastPathAndWait(t *testing.T) { + r, _ := makeRegistry(t) + // NewRegistry SetupInitialTrio(0) → {0,1}; TrioAt(0) is immediate. + trio, err := r.WaitForTrio(t.Context(), 0) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(0), trio.Current.EpochIndex()) + + // Tipcut into epoch 1 needs epoch 2. Seed after WaitForTrio is blocked. + tip := EpochLength + _, err = r.TrioAt(tip) + require.Error(t, err) + + type result struct { + trio types.EpochTrio + err error + } + done := make(chan result, 1) + go func() { + trio, err := r.WaitForTrio(t.Context(), tip) + done <- result{trio, err} + }() + r.AdvanceIfNeeded(0) // seeds epoch 2 + got := <-done + require.NoError(t, got.err) + require.Equal(t, types.EpochIndex(1), got.trio.Current.EpochIndex()) + require.Equal(t, types.EpochIndex(2), got.trio.Next.EpochIndex()) +} From d0f5388fbad96f912be5cc71471c7c0b6ad635ae Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 15 Jul 2026 16:38:31 -0700 Subject: [PATCH 39/98] refactor(autobahn): rename advanceEpoch and store trio inside it Rename advanceEpochLanes to advanceEpoch and move epochTrio.Store into that helper so PushCommitQC only invokes the boundary transition. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/inner.go | 10 +++++----- sei-tendermint/internal/autobahn/avail/inner_test.go | 12 ++++++------ sei-tendermint/internal/autobahn/avail/state.go | 3 +-- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index be8011ca7c..597d9e93f2 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -118,7 +118,7 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt } } else if startEpochTrio.Current.EpochIndex() == 0 { // No anchor: don't raise appVotes to a tip epoch's FirstBlock — live - // advanceEpochLanes also leaves appVotes at the genesis floor. + // advanceEpoch also leaves appVotes at the genesis floor. i.appVotes.prune(startEpochTrio.Current.FirstBlock()) } @@ -139,7 +139,7 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt // Restore persisted blocks. Create queues on demand for any lane present // in the WAL — lanes outside the current epoch will be pruned by - // advanceEpochLanes in NewState if a boundary was crossed. + // advanceEpoch in NewState if a boundary was crossed. for lane, bs := range l.blocks { if len(bs) == 0 { continue @@ -180,11 +180,10 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, ep *types.Epoch) return i.votes[lane].q[n].laneQC(ep) } -// advanceEpochLanes ensures Current∪Next lanes exist and backfills Next's -// vote sets from stored signatures (pushVote only credits Current+Next at push time). +// advanceEpoch applies nextTrio at an epoch boundary. // // TODO(lane-expiry): do not delete old lanes here until epoch-scoped lane IDs exist. -func (i *inner) advanceEpochLanes(nextTrio types.EpochTrio) { +func (i *inner) advanceEpoch(nextTrio types.EpochTrio) { activeLanes := nextTrio.CurrentAndNextLanes() for lane := range activeLanes { if _, ok := i.blocks[lane]; !ok { @@ -206,6 +205,7 @@ func (i *inner) advanceEpochLanes(nextTrio types.EpochTrio) { voteQueue.q[n].applyEpoch(nextTrio.Next) } } + i.epochTrio.Store(nextTrio) } // prune advances the state to account for a new AppQC/CommitQC pair. diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 6f00d9bdf8..0ae4b867a7 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -882,7 +882,7 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { require.NotEqual(t, tipFirst, inner.appVotes.first) } -func TestAdvanceEpochLanes_AddsLanesKeepsOld(t *testing.T) { +func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) trio := utils.OrPanic1(registry.TrioAt(0)) @@ -909,12 +909,12 @@ func TestAdvanceEpochLanes_AddsLanesKeepsOld(t *testing.T) { i.nextBlockToPersist[bogusLane] = 0 i.persistedBlockStart[bogusLane] = 0 - i.advanceEpochLanes(trio) + i.advanceEpoch(trio) require.Contains(t, i.blocks, bogusLane, "old lanes must be retained until lane-expiry") require.Contains(t, i.blocks, realLane, "active lane removed incorrectly") } -func TestAdvanceEpochLanes_EmptyQueuesNoop(t *testing.T) { +func TestAdvanceEpoch_EmptyQueuesNoop(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) trio := utils.OrPanic1(registry.TrioAt(0)) @@ -924,13 +924,13 @@ func TestAdvanceEpochLanes_EmptyQueuesNoop(t *testing.T) { // No votes in any queue; advancing to the same trio is a safe no-op that // keeps the current lane set intact. - i.advanceEpochLanes(trio) + i.advanceEpoch(trio) for lane := range trio.Current.Committee().Lanes().All() { require.Contains(t, i.blocks, lane) } } -func TestAdvanceEpochLanes_RetainsPrevEpochLanes(t *testing.T) { +func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) @@ -949,7 +949,7 @@ func TestAdvanceEpochLanes_RetainsPrevEpochLanes(t *testing.T) { // trio1: Prev=epoch0, Current=epoch1, Next=epoch2 trio1 := utils.OrPanic1(registry.TrioAt(epoch.EpochLength)) - i.advanceEpochLanes(trio1) + i.advanceEpoch(trio1) // Epoch0 lane is now in Prev — must be retained for boundary QC collection. require.Contains(t, i.blocks, epoch0Lane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index e7e78e0823..ab82d62830 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -297,8 +297,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return nil } if nextTrio != nil { - inner.advanceEpochLanes(*nextTrio) - inner.epochTrio.Store(*nextTrio) + inner.advanceEpoch(*nextTrio) // Always wake: next-epoch lane quorum may already be silent in pushVote. } inner.commitQCs.pushBack(qc) From 89077db50e0415f80d1b5b66a10723a3845fb044 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 15 Jul 2026 17:06:58 -0700 Subject: [PATCH 40/98] fix(autobahn): gate CommitQC N+1 on AppQC for epoch N Keep avail at most one epoch ahead of the AppQC prune anchor; warn only when the wait actually blocks. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 34 +++++++++++++++- .../internal/autobahn/avail/state_test.go | 40 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index ab82d62830..88c7bd95ee 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -221,6 +221,32 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error return err } +// waitForAppQCEpoch blocks until latest AppQC is from epochIdx or later. +func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex) error { + for inner, ctrl := range s.inner.Lock() { + ready := func() bool { + appQC, ok := inner.latestAppQC.Get() + if !ok { + return false + } + return appQC.Proposal().EpochIndex() >= epochIdx + } + if ready() { + return nil + } + attrs := []any{slog.Uint64("want_epoch", uint64(epochIdx))} + if appQC, ok := inner.latestAppQC.Get(); ok { + attrs = append(attrs, + slog.Uint64("latest_app_qc_road", uint64(appQC.Proposal().RoadIndex())), + slog.Uint64("latest_app_qc_epoch", uint64(appQC.Proposal().EpochIndex())), + ) + } + logger.Warn("waiting for AppQC before accepting CommitQC from next epoch", attrs...) + return ctrl.WaitUntil(ctx, ready) + } + panic("unreachable") +} + // LastAppQC returns the latest observed AppQC. func (s *State) LastAppQC() utils.Option[*types.AppQC] { for inner := range s.inner.Lock() { @@ -263,7 +289,8 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi } // PushCommitQC pushes a CommitQC to the state. -// Waits until all previous CommitQCs are pushed. +// Waits for prior CommitQCs, and for AppQC of epoch N before accepting a +// CommitQC from N+1 (avail tip at most one epoch ahead of the AppQC anchor). func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() if idx > 0 { @@ -271,6 +298,11 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } + if qcEpoch := qc.Proposal().EpochIndex(); qcEpoch > 0 { + if err := s.waitForAppQCEpoch(ctx, qcEpoch-1); err != nil { + return err + } + } trio := s.epochTrio.Load() ep, err := trio.EpochForCommitRoad(idx) if err != nil { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 2731c2f6ee..c89d2f3630 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -377,6 +377,46 @@ func TestStateMismatchedQCs(t *testing.T) { }) } +func TestWaitForAppQCEpoch(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + committee := ep0.Committee() + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + timeout, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + require.ErrorIs(t, state.waitForAppQCEpoch(timeout, 0), context.DeadlineExceeded) + + lane := keys[0].Public() + b, err := state.ProduceLocalBlock(state.NextBlock(lane), types.GenPayload(rng)) + require.NoError(t, err) + laneQC := types.NewLaneQC(makeLaneVotes( + types.TestKeysWithWeight(committee, keys, committee.LaneQuorum()), + b.Msg().Block().Header(), + )) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: laneQC}, utils.None[*types.AppQC]()) + require.NoError(t, state.PushCommitQC(ctx, qc0)) + + appQC := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().Next-1, 0, types.GenAppHash(rng), 0))) + + done := make(chan error, 1) + go func() { done <- state.waitForAppQCEpoch(ctx, 0) }() + require.NoError(t, state.PushAppQC(appQC, qc0)) + require.NoError(t, <-done) + require.NoError(t, state.waitForAppQCEpoch(ctx, 0)) + + timeout2, cancel2 := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel2() + require.ErrorIs(t, state.waitForAppQCEpoch(timeout2, 1), context.DeadlineExceeded) +} + func TestPushBlockRejectsBadParentHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() From 483b9f4824e741aa1ea8050984472144f85e92f0 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 15 Jul 2026 18:03:47 -0700 Subject: [PATCH 41/98] fix(autobahn): drop stale out-of-window AppQCs Match PushAppVote: outside the EpochTrio window is a silent drop, not a peer-tearing error. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 5 ++- .../internal/autobahn/avail/state_test.go | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 88c7bd95ee..f2fce980c9 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -401,7 +401,10 @@ func (s *State) PushAppQC(appQC *types.AppQC, commitQC *types.CommitQC) error { } ep, err := s.epochTrio.Load().EpochForRoad(commitQC.Proposal().Index()) if err != nil { - return fmt.Errorf("EpochForRoad(%d): %w", commitQC.Proposal().Index(), err) + // Out-of-window ⇒ stale; drop (do not tear down the peer). + logger.Info("dropping stale AppQC: road outside epoch window", + slog.Uint64("road", uint64(commitQC.Proposal().Index())), "err", err) + return nil } if err := appQC.Verify(ep.Committee()); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index c89d2f3630..702c744127 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -857,6 +857,37 @@ func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { })) } +func TestPushAppQCOutsideWindowDrops(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + lane := keys[0].Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block.Header()))}, + utils.None[*types.AppQC]()) + appQC := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + + // Center the operating window on epoch 2 so road 0 is outside Prev|Current|Next. + for _, road := range []types.RoadIndex{0, epoch.EpochLength, 2 * epoch.EpochLength} { + registry.AdvanceIfNeeded(road) + } + farTrio := utils.OrPanic1(registry.TrioAt(2 * epoch.EpochLength)) + for inner := range state.inner.Lock() { + inner.epochTrio.Store(farTrio) + } + + require.NoError(t, state.PushAppQC(appQC, qc0)) + require.False(t, state.LastAppQC().IsPresent()) +} + // TestPushAppQCPreviousEpoch verifies that an AppQC whose road index falls in // epoch N-1 is accepted when the registry is seeded at epoch N. This exercises // the path where a late AppQC arrives after an epoch boundary has been crossed. From d32df8337603e40db6ef0e404ffd40230ed65086 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 15:31:34 -0700 Subject: [PATCH 42/98] refactor(autobahn): make RoadRange half-open [First, Next) Align with GlobalRange/LaneRange; document epoch-0 AppQC wait and Last-CommitQC trio advance. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/committee_test.go | 4 ++-- sei-tendermint/autobahn/types/epoch.go | 13 +++++++------ sei-tendermint/autobahn/types/epoch_trio.go | 2 +- sei-tendermint/autobahn/types/epoch_trio_test.go | 12 ++++++------ sei-tendermint/autobahn/types/proposal.go | 2 +- sei-tendermint/autobahn/types/testonly.go | 2 +- .../internal/autobahn/avail/block_votes_test.go | 2 +- .../internal/autobahn/avail/inner_test.go | 4 ++-- sei-tendermint/internal/autobahn/avail/state.go | 8 ++++++-- .../internal/autobahn/avail/state_test.go | 2 +- sei-tendermint/internal/autobahn/data/state.go | 2 +- sei-tendermint/internal/autobahn/epoch/registry.go | 7 +++---- .../internal/autobahn/epoch/registry_test.go | 6 +++--- 13 files changed, 35 insertions(+), 31 deletions(-) diff --git a/sei-tendermint/autobahn/types/committee_test.go b/sei-tendermint/autobahn/types/committee_test.go index 996e2a812a..413568bb86 100644 --- a/sei-tendermint/autobahn/types/committee_test.go +++ b/sei-tendermint/autobahn/types/committee_test.go @@ -132,7 +132,7 @@ func TestPrepareQCVerifyChecksEpochBinding(t *testing.T) { wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) require.Error(t, sign(wrongEpoch).Verify(ep)) - outOfRoads := newProposal(View{Index: ep.RoadRange().Last + 1, EpochIndex: ep.EpochIndex()}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) + outOfRoads := newProposal(View{Index: ep.RoadRange().Next, EpochIndex: ep.EpochIndex()}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) require.Error(t, sign(outOfRoads).Verify(ep)) } @@ -148,7 +148,7 @@ func TestCommitQCVerifyChecksEpochBinding(t *testing.T) { wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) require.Error(t, sign(wrongEpoch).Verify(ep)) - outOfRoads := newProposal(View{Index: ep.RoadRange().Last + 1, EpochIndex: ep.EpochIndex()}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) + outOfRoads := newProposal(View{Index: ep.RoadRange().Next, EpochIndex: ep.EpochIndex()}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) require.Error(t, sign(outOfRoads).Verify(ep)) } diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index 3ab13df54a..d32d071c4f 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -9,18 +9,19 @@ import ( // EpochIndex is the epoch number. type EpochIndex uint64 -// RoadRange is an inclusive range of RoadIndex values [First, Last]. +// RoadRange is a half-open range of RoadIndex values [First, Next). +// Matches GlobalRange / LaneRange: Next is exclusive (Next == lastInclusive+1). type RoadRange struct { First RoadIndex - Last RoadIndex + Next RoadIndex } -// OpenRoadRange returns a RoadRange covering all road indices from 0. +// OpenRoadRange returns a RoadRange covering road indices [0, Max). // Use in tests and genesis epochs where no upper bound is known yet. -func OpenRoadRange() RoadRange { return RoadRange{First: 0, Last: utils.Max[RoadIndex]()} } +func OpenRoadRange() RoadRange { return RoadRange{First: 0, Next: utils.Max[RoadIndex]()} } -// Has reports whether idx falls within this range (inclusive on both ends). -func (r RoadRange) Has(idx RoadIndex) bool { return idx >= r.First && idx <= r.Last } +// Has reports whether idx falls within this range [First, Next). +func (r RoadRange) Has(idx RoadIndex) bool { return idx >= r.First && idx < r.Next } // Epoch holds the complete context for a single epoch. // Retrieved from the local Registry; never transmitted on the wire. diff --git a/sei-tendermint/autobahn/types/epoch_trio.go b/sei-tendermint/autobahn/types/epoch_trio.go index f16e0ff662..4d680da8d4 100644 --- a/sei-tendermint/autobahn/types/epoch_trio.go +++ b/sei-tendermint/autobahn/types/epoch_trio.go @@ -38,7 +38,7 @@ func (w EpochTrio) EpochForCommitRoad(roadIdx RoadIndex) (*Epoch, error) { if w.Next.RoadRange().Has(roadIdx) { return w.Next, nil } - if roadIdx > w.Next.RoadRange().Last { + if roadIdx >= w.Next.RoadRange().Next { panic(fmt.Sprintf("CommitQC road %d above Next %v: impossible after waitForCommitQC in-order tip", roadIdx, w)) } return nil, fmt.Errorf("CommitQC road %d before Current in %v", roadIdx, w) diff --git a/sei-tendermint/autobahn/types/epoch_trio_test.go b/sei-tendermint/autobahn/types/epoch_trio_test.go index 79149bda56..0451ac6bf0 100644 --- a/sei-tendermint/autobahn/types/epoch_trio_test.go +++ b/sei-tendermint/autobahn/types/epoch_trio_test.go @@ -18,9 +18,9 @@ func makeThreeEpochs(t *testing.T) (prev, current, next *types.Epoch, keys []typ weights[sk.Public()] = 1 } committee := utils.OrPanic1(types.NewCommittee(weights)) - prev = types.NewEpoch(0, types.RoadRange{First: 0, Last: 99}, utils.GenTimestamp(rng), committee, 1) - current = types.NewEpoch(1, types.RoadRange{First: 100, Last: 199}, utils.GenTimestamp(rng), committee, 101) - next = types.NewEpoch(2, types.RoadRange{First: 200, Last: 299}, utils.GenTimestamp(rng), committee, 201) + prev = types.NewEpoch(0, types.RoadRange{First: 0, Next: 100}, utils.GenTimestamp(rng), committee, 1) + current = types.NewEpoch(1, types.RoadRange{First: 100, Next: 200}, utils.GenTimestamp(rng), committee, 101) + next = types.NewEpoch(2, types.RoadRange{First: 200, Next: 300}, utils.GenTimestamp(rng), committee, 201) return prev, current, next, sks } @@ -113,7 +113,7 @@ func TestEpochForCommitRoad_AboveNextPanics(t *testing.T) { } func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { - // Regression: genesis epoch used to have OpenRoadRange (Last=MaxUint64). + // Regression: genesis epoch used to have OpenRoadRange (Next=MaxUint64). // EpochForRoad iterated [Prev, Current, Next], so Prev.Has(any) was always // true — every lookup returned epoch 0 instead of the correct epoch. rng := utils.TestRng() @@ -124,8 +124,8 @@ func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { } committee := utils.OrPanic1(types.NewCommittee(weights)) openEpoch := types.NewEpoch(0, types.OpenRoadRange(), utils.GenTimestamp(rng), committee, 1) - current := types.NewEpoch(1, types.RoadRange{First: 100, Last: 199}, utils.GenTimestamp(rng), committee, 101) - next := types.NewEpoch(2, types.RoadRange{First: 200, Last: 299}, utils.GenTimestamp(rng), committee, 201) + current := types.NewEpoch(1, types.RoadRange{First: 100, Next: 200}, utils.GenTimestamp(rng), committee, 101) + next := types.NewEpoch(2, types.RoadRange{First: 200, Next: 300}, utils.GenTimestamp(rng), committee, 201) w := types.EpochTrio{Prev: utils.Some(openEpoch), Current: current, Next: next} ep, err := w.EpochForRoad(150) if err != nil { diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 2206e65e8b..8877d06d83 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -110,7 +110,7 @@ func (v View) Verify(ep *Epoch) error { return fmt.Errorf("epoch_index = %d, want %d", got, want) } if rr := ep.RoadRange(); !rr.Has(v.Index) { - return fmt.Errorf("road_index %v not in epoch roads [%v, %v]", v.Index, rr.First, rr.Last) + return fmt.Errorf("road_index %v not in epoch roads [%v, %v)", v.Index, rr.First, rr.Next) } return nil } diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index 386fce70ff..e573bb78c4 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -276,7 +276,7 @@ func GenEpochWithCommittee(rng utils.Rng, committee *Committee) *Epoch { first := RoadIndex(rng.Uint64() % 1000) return NewEpoch( GenEpochIndex(rng), - RoadRange{First: first, Last: first + RoadIndex(rng.Uint64()%10000) + 10}, + RoadRange{First: first, Next: first + RoadIndex(rng.Uint64()%10000) + 11}, utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1, diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 892604512e..8d56037b35 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -12,7 +12,7 @@ import ( func makeVoteEpoch(idx types.EpochIndex, weights map[types.PublicKey]uint64) *types.Epoch { c := utils.OrPanic1(types.NewCommittee(weights)) first := types.RoadIndex(uint64(idx) * 108_000) - rr := types.RoadRange{First: first, Last: first + 107_999} + rr := types.RoadRange{First: first, Next: first + 108_000} return types.NewEpoch(idx, rr, time.Time{}, c, 0) } diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 0ae4b867a7..ded9fe9ec3 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -859,8 +859,8 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { // registry epochs share genesis FirstBlock, so inflate for this invariant). tipFirst := wantAppFirst + 1000 c := ep0.Committee() - tipCurrent := types.NewEpoch(1, types.RoadRange{First: epoch.EpochLength, Last: 2*epoch.EpochLength - 1}, ep0.FirstTimestamp(), c, tipFirst) - tipNext := types.NewEpoch(2, types.RoadRange{First: 2 * epoch.EpochLength, Last: 3*epoch.EpochLength - 1}, ep0.FirstTimestamp(), c, tipFirst) + tipCurrent := types.NewEpoch(1, types.RoadRange{First: epoch.EpochLength, Next: 2 * epoch.EpochLength}, ep0.FirstTimestamp(), c, tipFirst) + tipNext := types.NewEpoch(2, types.RoadRange{First: 2 * epoch.EpochLength, Next: 3 * epoch.EpochLength}, ep0.FirstTimestamp(), c, tipFirst) tipTrio := types.EpochTrio{Prev: utils.Some(ep0), Current: tipCurrent, Next: tipNext} ap := types.NewAppProposal(wantAppFirst, qcs[1].Index(), types.GenAppHash(rng), 0) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index f2fce980c9..067a01e77d 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -298,6 +298,8 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } + // Epochs are numbered from 0 (unlike global block numbers). Epoch 0 has no + // prior AppQC to wait for; N+1 requires AppQC of epoch N. if qcEpoch := qc.Proposal().EpochIndex(); qcEpoch > 0 { if err := s.waitForAppQCEpoch(ctx, qcEpoch-1); err != nil { return err @@ -314,9 +316,11 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return fmt.Errorf("qc.Verify(): %w", err) } - // Boundary: resolve next trio off-lock (WaitForTrio). + // Boundary: switch to the next epoch on Current's last CommitQC (not the + // first of Next), so we can immediately collect votes for new-epoch lanes. + // Resolve next trio off-lock (WaitForTrio). var nextTrio *types.EpochTrio - if idx == trio.Current.RoadRange().Last { + if idx+1 == trio.Current.RoadRange().Next { nt, err := s.data.Registry().WaitForTrio(ctx, idx+1) if err != nil { return err diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 702c744127..bba82cb8e6 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -935,7 +935,7 @@ func TestRestartTrioFromCommitTipNeedsNPlus2(t *testing.T) { // GenRegistry/NewRegistry → SetupInitialTrio(0) → epochs {0,1} only. ep0, err := registry.TrioAt(0) require.NoError(t, err) - tip := ep0.Current.RoadRange().Last + 1 // first road of epoch 1 + tip := ep0.Current.RoadRange().Next // first road of epoch 1 require.Equal(t, types.RoadIndex(epoch.EpochLength), tip) _, err = registry.TrioAt(tip) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 4e2efc3d85..fe28dcee4f 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -426,7 +426,7 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty idx := qc.QC().Proposal().Index() var nextTrio *types.EpochTrio trio := s.epochTrio.Load() - if needQC && idx == trio.Current.RoadRange().Last { + if needQC && idx+1 == trio.Current.RoadRange().Next { nt, err := s.cfg.Registry.WaitForTrio(ctx, idx+1) if err != nil { return err diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index eebfa8f9c4..87f01b5d7c 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -33,7 +33,7 @@ func NewRegistry( firstBlock types.GlobalBlockNumber, genesisTimestamp time.Time, ) (*Registry, error) { - ep := types.NewEpoch(0, types.RoadRange{First: 0, Last: EpochLength - 1}, genesisTimestamp, committee, firstBlock) + ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: EpochLength}, genesisTimestamp, committee, firstBlock) r := &Registry{ state: utils.NewRWMutex(®istryState{ m: map[types.EpochIndex]*types.Epoch{0: ep}, @@ -101,8 +101,7 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type return nil, fmt.Errorf("genesis epoch missing from registry") } firstRoad := types.RoadIndex(uint64(epochIdx) * uint64(EpochLength)) - lastRoad := firstRoad + EpochLength - 1 - epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Last: lastRoad}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) + epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: firstRoad + EpochLength}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) s.m[epochIdx] = epoch // Wake WaitForTrio waiters. makeEpoch runs under the write lock, so this // Load/Store is serialized; highestEpoch only advances. @@ -113,7 +112,7 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type } // AdvanceIfNeeded seeds epoch N+2 when any road in epoch N is executed. -// Invariant: by the time Tipcut needs TrioAt(N.Last+1), N+2 is either already +// Invariant: by the time Tipcut needs TrioAt(N.Next), N+2 is either already // registered or waiters use WaitForTrio until this runs. // TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 0e410d7cda..5c6c7914d1 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -28,8 +28,8 @@ func TestNewRegistry_GenesisEpochBoundedRange(t *testing.T) { t.Fatalf("EpochAt(0): %v", err) } rng := ep.RoadRange() - if rng.First != 0 || rng.Last != EpochLength-1 { - t.Fatalf("genesis RoadRange = {%d, %d}, want {0, %d}", rng.First, rng.Last, EpochLength-1) + if rng.First != 0 || rng.Next != EpochLength { + t.Fatalf("genesis RoadRange = {%d, %d}, want {0, %d}", rng.First, rng.Next, EpochLength) } } @@ -141,7 +141,7 @@ func TestTrioAt_ErrorWhenNextMissing(t *testing.T) { committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ types.GenSecretKey(utils.TestRng()).Public(): 1, })) - ep := types.NewEpoch(0, types.RoadRange{First: 0, Last: EpochLength - 1}, time.Time{}, committee, 0) + ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: EpochLength}, time.Time{}, committee, 0) bare := &Registry{ state: utils.NewRWMutex(®istryState{ m: map[types.EpochIndex]*types.Epoch{0: ep}, From c5ad50c8c9d4bc2fd09b8934b71e152fd9342b7e Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 15:35:21 -0700 Subject: [PATCH 43/98] refactor(autobahn): rename commitTipRoad to nextCommitQC Make it a method on loadedAvailState per review feedback. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 11 ++++------ .../internal/autobahn/avail/state.go | 5 ++++- .../internal/autobahn/avail/state_test.go | 20 +++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 597d9e93f2..9815d88a80 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -57,14 +57,11 @@ type loadedAvailState struct { blocks map[types.LaneID][]persist.LoadedBlock } -// commitTipRoad is the tipcut road after restore (commitQCs.next): one past the -// last loaded CommitQC, floored by the prune-anchor tipcut when the WAL lags. -func commitTipRoad(loaded utils.Option[*loadedAvailState]) types.RoadIndex { +// nextCommitQC is the index of the next CommitQC to be inserted after restore: +// one past the last loaded CommitQC, floored by the prune-anchor tipcut when +// the WAL lags. +func (ls *loadedAvailState) nextCommitQC() types.RoadIndex { tip := types.RoadIndex(0) - ls, ok := loaded.Get() - if !ok { - return tip - } if n := len(ls.commitQCs); n > 0 { tip = ls.commitQCs[n-1].Index + 1 } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 067a01e77d..743ea26d52 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -159,7 +159,10 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin // Operating trio is the CommitQC tipcut (not the prune-anchor road). // Tip may lead data.SetupInitialTrio; seed around it before TrioAt. - commitTip := commitTipRoad(loaded) + commitTip := types.RoadIndex(0) + if ls, ok := loaded.Get(); ok { + commitTip = ls.nextCommitQC() + } data.Registry().SetupInitialTrio(commitTip) startTrio, err := data.Registry().TrioAt(commitTip) if err != nil { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index bba82cb8e6..24b41c4651 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -955,10 +955,10 @@ func TestRestartTrioFromCommitTipNeedsNPlus2(t *testing.T) { require.Equal(t, types.EpochIndex(2), got.Next.EpochIndex()) } -func TestCommitTipRoad(t *testing.T) { +func TestNextCommitQC(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 4) - require.Equal(t, types.RoadIndex(0), commitTipRoad(utils.None[*loadedAvailState]())) + require.Equal(t, types.RoadIndex(0), (&loadedAvailState{}).nextCommitQC()) qcs := make([]*types.CommitQC, 10) prev := utils.None[*types.CommitQC]() @@ -966,36 +966,36 @@ func TestCommitTipRoad(t *testing.T) { qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } - require.Equal(t, types.RoadIndex(3), commitTipRoad(utils.Some(&loadedAvailState{ + require.Equal(t, types.RoadIndex(3), (&loadedAvailState{ commitQCs: []persist.LoadedCommitQC{ {Index: 0, QC: qcs[0]}, {Index: 1, QC: qcs[1]}, {Index: 2, QC: qcs[2]}, }, - }))) + }).nextCommitQC()) // Loaded tip ahead of prune anchor → tip from last QC. gr1 := qcs[1].GlobalRange() appQC1 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal(gr1.First, qcs[1].Index(), types.GenAppHash(rng), 0))) - require.Equal(t, types.RoadIndex(3), commitTipRoad(utils.Some(&loadedAvailState{ + require.Equal(t, types.RoadIndex(3), (&loadedAvailState{ pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC1, CommitQC: qcs[1]}), commitQCs: []persist.LoadedCommitQC{ {Index: 1, QC: qcs[1]}, {Index: 2, QC: qcs[2]}, }, - }))) + }).nextCommitQC()) // WAL empty / lagging behind prune anchor → tip from max(., anchor+1). gr9 := qcs[9].GlobalRange() appQC9 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal(gr9.First, qcs[9].Index(), types.GenAppHash(rng), 0))) - require.Equal(t, types.RoadIndex(10), commitTipRoad(utils.Some(&loadedAvailState{ + require.Equal(t, types.RoadIndex(10), (&loadedAvailState{ pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC9, CommitQC: qcs[9]}), - }))) - require.Equal(t, types.RoadIndex(10), commitTipRoad(utils.Some(&loadedAvailState{ + }).nextCommitQC()) + require.Equal(t, types.RoadIndex(10), (&loadedAvailState{ pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC9, CommitQC: qcs[9]}), commitQCs: []persist.LoadedCommitQC{ {Index: 0, QC: qcs[0]}, {Index: 1, QC: qcs[1]}, }, - })), "stale WAL below anchor must not win over anchor tipcut") + }).nextCommitQC(), "stale WAL below anchor must not win over anchor tipcut") } From 7126aeee14ca0a40d7dbbebecdaee13f72e166ba Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 15:43:16 -0700 Subject: [PATCH 44/98] refactor(autobahn): bundle per-lane avail state in laneState Combine blocks, votes, and persistence cursors into inner.lanes per review feedback. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 125 ++++++++---------- .../internal/autobahn/avail/inner_test.go | 67 +++++----- .../internal/autobahn/avail/state.go | 53 ++++---- .../internal/autobahn/avail/subscriptions.go | 8 +- 4 files changed, 118 insertions(+), 135 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 9815d88a80..004a4af618 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -10,39 +10,43 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// TODO: when dynamic committee changes are supported, newly joined members -// must be added to blocks, votes, nextBlockToPersist, and persistedBlockStart. -// Currently all four are initialized once in newInner from c.Lanes().All(). -// BlockPersister creates lane WALs lazily inside MaybePruneAndPersistLane, but the new -// member must also appear in inner.blocks before the next persist cycle. +// TODO: add dynamic committee members via getOrInsertLane. type inner struct { latestAppQC utils.Option[*types.AppQC] latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] epochTrio utils.AtomicSend[types.EpochTrio] // Store under Lock; State holds Recv appVotes *queue[types.GlobalBlockNumber, appVotes] commitQCs *queue[types.RoadIndex, *types.CommitQC] - blocks map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] - votes map[types.LaneID]*queue[types.BlockNumber, blockVotes] - // nextBlockToPersist tracks per-lane how far block persistence has progressed. - // RecvBatch only yields blocks below this cursor for voting. - // Always initialized (even when persistence is disabled — the no-op persist - // goroutine bumps it immediately). Not persisted to disk: on restart it is - // reconstructed from the blocks already on disk (see newInner). + lanes map[types.LaneID]*laneState +} + +// laneState fields share the same lifecycle. +type laneState struct { + blocks *queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] + votes *queue[types.BlockNumber, blockVotes] + // nextBlockToPersist is reconstructed from persisted blocks on restart. // // TODO: consider giving this its own AtomicSend to avoid waking unrelated // inner waiters (PushVote, PushCommitQC, etc.) on markBlockPersisted calls. - // Now that blocks are persisted concurrently by lane (one notification per - // lane per batch, not per block), the frequency is lower, but still not - // ideal. Only RecvBatch needs to be notified of cursor changes; - // collectPersistBatch is in the same goroutine and reads it directly. - nextBlockToPersist map[types.LaneID]types.BlockNumber - - // persistedBlockStart is the per-lane block number derived from the last - // durably persisted prune anchor. Block admission (PushBlock, ProduceBlock, - // WaitForCapacity, PushVote) uses persistedBlockStart + BlocksPerLane as - // the capacity limit, ensuring we never admit more blocks than can be - // recovered after a crash. - persistedBlockStart map[types.LaneID]types.BlockNumber + nextBlockToPersist types.BlockNumber + // persistedBlockStart is the admission watermark from the prune anchor. + persistedBlockStart types.BlockNumber +} + +func newLaneState() *laneState { + return &laneState{ + blocks: newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]](), + votes: newQueue[types.BlockNumber, blockVotes](), + } +} + +func (i *inner) getOrInsertLane(lane types.LaneID) *laneState { + if ls, ok := i.lanes[lane]; ok { + return ls + } + ls := newLaneState() + i.lanes[lane] = ls + return ls } // loadedAvailState holds data loaded from disk on restart. @@ -72,24 +76,18 @@ func (ls *loadedAvailState) nextCommitQC() types.RoadIndex { } func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailState]) (*inner, error) { - lanes := startEpochTrio.CurrentAndNextLanes() - votes := map[types.LaneID]*queue[types.BlockNumber, blockVotes]{} - blocks := map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{} - for lane := range lanes { - votes[lane] = newQueue[types.BlockNumber, blockVotes]() - blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() + lanes := map[types.LaneID]*laneState{} + for lane := range startEpochTrio.CurrentAndNextLanes() { + lanes[lane] = newLaneState() } i := &inner{ - latestAppQC: utils.None[*types.AppQC](), - latestCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), - epochTrio: utils.NewAtomicSend(startEpochTrio), - appVotes: newQueue[types.GlobalBlockNumber, appVotes](), - commitQCs: newQueue[types.RoadIndex, *types.CommitQC](), - blocks: blocks, - votes: votes, - nextBlockToPersist: make(map[types.LaneID]types.BlockNumber, len(votes)), - persistedBlockStart: make(map[types.LaneID]types.BlockNumber, len(votes)), + latestAppQC: utils.None[*types.AppQC](), + latestCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + epochTrio: utils.NewAtomicSend(startEpochTrio), + appVotes: newQueue[types.GlobalBlockNumber, appVotes](), + commitQCs: newQueue[types.RoadIndex, *types.CommitQC](), + lanes: lanes, } l, ok := loaded.Get() if !ok { @@ -110,8 +108,8 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt if _, err := i.prune(anchor.AppQC, anchor.CommitQC); err != nil { return nil, fmt.Errorf("prune: %w", err) } - for lane := range i.blocks { - i.persistedBlockStart[lane] = anchor.CommitQC.LaneRange(lane).First() + for lane, ls := range i.lanes { + ls.persistedBlockStart = anchor.CommitQC.LaneRange(lane).First() } } else if startEpochTrio.Current.EpochIndex() == 0 { // No anchor: don't raise appVotes to a tip epoch's FirstBlock — live @@ -141,13 +139,8 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt if len(bs) == 0 { continue } - if _, ok := i.blocks[lane]; !ok { - i.blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() - i.votes[lane] = newQueue[types.BlockNumber, blockVotes]() - i.nextBlockToPersist[lane] = 0 - i.persistedBlockStart[lane] = 0 - } - q := i.blocks[lane] + ls := i.getOrInsertLane(lane) + q := ls.blocks var lastHash types.BlockHeaderHash for j, b := range bs { if q.Len() >= BlocksPerLane { @@ -165,7 +158,7 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt q.pushBack(b.Proposal) } if q.next > q.first { - i.nextBlockToPersist[lane] = q.next + ls.nextBlockToPersist = q.next } } @@ -174,32 +167,20 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt // laneQC returns a stored LaneQC for (lane, n) under ep, if any. func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, ep *types.Epoch) utils.Option[*types.LaneQC] { - return i.votes[lane].q[n].laneQC(ep) + return i.lanes[lane].votes.q[n].laneQC(ep) } // advanceEpoch applies nextTrio at an epoch boundary. // // TODO(lane-expiry): do not delete old lanes here until epoch-scoped lane IDs exist. func (i *inner) advanceEpoch(nextTrio types.EpochTrio) { - activeLanes := nextTrio.CurrentAndNextLanes() - for lane := range activeLanes { - if _, ok := i.blocks[lane]; !ok { - i.blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() - } - if _, ok := i.votes[lane]; !ok { - i.votes[lane] = newQueue[types.BlockNumber, blockVotes]() - } - if _, ok := i.nextBlockToPersist[lane]; !ok { - i.nextBlockToPersist[lane] = 0 - } - if _, ok := i.persistedBlockStart[lane]; !ok { - i.persistedBlockStart[lane] = 0 - } + for lane := range nextTrio.CurrentAndNextLanes() { + i.getOrInsertLane(lane) } // Seed the newly-entering Next epoch's vote sets from votes already stored. - for _, voteQueue := range i.votes { - for n := voteQueue.first; n < voteQueue.next; n++ { - voteQueue.q[n].applyEpoch(nextTrio.Next) + for _, ls := range i.lanes { + for n := ls.votes.first; n < ls.votes.next; n++ { + ls.votes.q[n].applyEpoch(nextTrio.Next) } } i.epochTrio.Store(nextTrio) @@ -223,12 +204,12 @@ func (i *inner) prune(appQC *types.AppQC, commitQC *types.CommitQC) (bool, error metrics.ObserveCommitQC(commitQC) } i.appVotes.prune(commitQC.GlobalRange().First) - for lane := range i.votes { + for lane, ls := range i.lanes { lr := commitQC.LaneRange(lane) - i.votes[lr.Lane()].prune(lr.First()) - i.blocks[lr.Lane()].prune(lr.First()) - if i.nextBlockToPersist[lr.Lane()] < lr.First() { - i.nextBlockToPersist[lr.Lane()] = lr.First() + ls.votes.prune(lr.First()) + ls.blocks.prune(lr.First()) + if ls.nextBlockToPersist < lr.First() { + ls.nextBlockToPersist = lr.First() } } return true, nil diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index ded9fe9ec3..86be648c64 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -72,16 +72,16 @@ func TestNewInnerFreshStart(t *testing.T) { require.NoError(t, err) require.False(t, i.latestAppQC.IsPresent()) - require.NotNil(t, i.nextBlockToPersist) + require.NotNil(t, i.lanes) require.Equal(t, types.RoadIndex(0), i.commitQCs.first) require.Equal(t, types.RoadIndex(0), i.commitQCs.next) require.Equal(t, registry.FirstBlock(), i.appVotes.first) require.Equal(t, registry.FirstBlock(), i.appVotes.next) for lane := range registry.LatestEpoch().Committee().Lanes().All() { - require.Equal(t, types.BlockNumber(0), i.blocks[lane].first) - require.Equal(t, types.BlockNumber(0), i.blocks[lane].next) - require.Equal(t, types.BlockNumber(0), i.votes[lane].first) - require.Equal(t, types.BlockNumber(0), i.votes[lane].next) + require.Equal(t, types.BlockNumber(0), i.lanes[lane].blocks.first) + require.Equal(t, types.BlockNumber(0), i.lanes[lane].blocks.next) + require.Equal(t, types.BlockNumber(0), i.lanes[lane].votes.first) + require.Equal(t, types.BlockNumber(0), i.lanes[lane].votes.next) } } @@ -135,7 +135,7 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) - q := i.blocks[lane] + q := i.lanes[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(3), q.next) for j, b := range bs { @@ -143,11 +143,11 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { } // nextBlockToPersist: loaded lane at q.next, other lanes at 0 (map zero-value). - require.NotNil(t, i.nextBlockToPersist) - require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane]) + require.NotNil(t, i.lanes) + require.Equal(t, types.BlockNumber(3), i.lanes[lane].nextBlockToPersist) for other := range registry.LatestEpoch().Committee().Lanes().All() { if other != lane { - require.Equal(t, types.BlockNumber(0), i.nextBlockToPersist[other]) + require.Equal(t, types.BlockNumber(0), i.lanes[other].nextBlockToPersist) } } } @@ -164,7 +164,7 @@ func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) - q := i.blocks[lane] + q := i.lanes[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(0), q.next) } @@ -185,7 +185,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { require.NoError(t, err) for lane := range registry.LatestEpoch().Committee().Lanes().All() { - q := i.blocks[lane] + q := i.lanes[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(0), q.next) } @@ -221,17 +221,17 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) require.NoError(t, err) - q0 := i.blocks[lane0] + q0 := i.lanes[lane0].blocks require.Equal(t, types.BlockNumber(0), q0.first) require.Equal(t, types.BlockNumber(2), q0.next) - q1 := i.blocks[lane1] + q1 := i.lanes[lane1].blocks require.Equal(t, types.BlockNumber(0), q1.first) require.Equal(t, types.BlockNumber(3), q1.next) // nextBlockToPersist reflects q.next per loaded lane. - require.Equal(t, types.BlockNumber(2), i.nextBlockToPersist[lane0]) - require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane1]) + require.Equal(t, types.BlockNumber(2), i.lanes[lane0].nextBlockToPersist) + require.Equal(t, types.BlockNumber(3), i.lanes[lane1].nextBlockToPersist) } func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { @@ -375,10 +375,10 @@ func TestNewInnerLoadedAllThree(t *testing.T) { require.Equal(t, types.RoadIndex(5), inner.commitQCs.next) // Blocks loaded. - q := inner.blocks[lane] + q := inner.lanes[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(3), q.next) - require.Equal(t, types.BlockNumber(3), inner.nextBlockToPersist[lane]) + require.Equal(t, types.BlockNumber(3), inner.lanes[lane].nextBlockToPersist) // latestCommitQC is the last loaded one. latest, ok := inner.latestCommitQC.Load().Get() @@ -399,10 +399,10 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { for n := range types.BlockNumber(5) { b := testSignedBlock(keys[0], lane, n, parent, rng) parent = b.Msg().Block().Header().Hash() - i.blocks[lane].pushBack(b) + i.lanes[lane].blocks.pushBack(b) } // Simulate partial persistence: only block 0 persisted. - i.nextBlockToPersist[lane] = 1 + i.lanes[lane].nextBlockToPersist = 1 // Build CommitQCs with lane ranges that reference actual blocks. // Each CommitQC covers one block on the lane via a LaneQC. @@ -410,7 +410,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { prev := utils.None[*types.CommitQC]() for j := range qcs { bn := types.BlockNumber(j) - h := i.blocks[lane].q[bn].Msg().Block().Header() + h := i.lanes[lane].blocks.q[bn].Msg().Block().Header() laneQCs := map[types.LaneID]*types.LaneQC{ lane: types.NewLaneQC(makeLaneVotes( types.TestKeysWithWeight(registry.LatestEpoch().Committee(), keys, registry.LatestEpoch().Committee().LaneQuorum()), @@ -438,10 +438,10 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { // nextBlockToPersist must have advanced to at least the lane's new first // (determined by CommitQC@2's lane range). Without this fix, it would // stay at 1, causing the persist goroutine to busy-loop. - laneFirst := i.blocks[lane].first + laneFirst := i.lanes[lane].blocks.first require.Greater(t, laneFirst, types.BlockNumber(1), "prune should have advanced blocks.first past the old cursor") - require.GreaterOrEqual(t, i.nextBlockToPersist[lane], laneFirst, + require.GreaterOrEqual(t, i.lanes[lane].nextBlockToPersist, laneFirst, "nextBlockToPersist should advance when prune moves blocks.first past it") } @@ -511,7 +511,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { // persistedBlockStart should be initialized from the anchor's CommitQC. for lane := range registry.LatestEpoch().Committee().Lanes().All() { expected := qcs[3].LaneRange(lane).First() - require.Equal(t, expected, inner.persistedBlockStart[lane]) + require.Equal(t, expected, inner.lanes[lane].persistedBlockStart) } } @@ -801,8 +801,8 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { // prune() should advance block queue first to the prune anchor's lane range. for l := range registry.LatestEpoch().Committee().Lanes().All() { expected := pruneQC.LaneRange(l).First() - require.Equal(t, expected, i.blocks[l].first, - "blocks[%v].first should be advanced by prune to prune anchor lane range", l) + require.Equal(t, expected, i.lanes[l].blocks.first, + "lanes[%v].blocks.first should be advanced by prune to prune anchor lane range", l) } } @@ -892,7 +892,7 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { // All current lanes are present after construction. for lane := range trio.Current.Committee().Lanes().All() { - require.Contains(t, i.blocks, lane, "lane %v missing after newInner", lane) + require.Contains(t, i.lanes, lane, "lane %v missing after newInner", lane) } // Add a lane not in the trio — until lane-expiry lands, advance must not @@ -904,14 +904,11 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { } bogusSK := types.GenSecretKey(rng) bogusLane := bogusSK.Public() - i.blocks[bogusLane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() - i.votes[bogusLane] = newQueue[types.BlockNumber, blockVotes]() - i.nextBlockToPersist[bogusLane] = 0 - i.persistedBlockStart[bogusLane] = 0 + i.lanes[bogusLane] = newLaneState() i.advanceEpoch(trio) - require.Contains(t, i.blocks, bogusLane, "old lanes must be retained until lane-expiry") - require.Contains(t, i.blocks, realLane, "active lane removed incorrectly") + require.Contains(t, i.lanes, bogusLane, "old lanes must be retained until lane-expiry") + require.Contains(t, i.lanes, realLane, "active lane removed incorrectly") } func TestAdvanceEpoch_EmptyQueuesNoop(t *testing.T) { @@ -926,7 +923,7 @@ func TestAdvanceEpoch_EmptyQueuesNoop(t *testing.T) { // keeps the current lane set intact. i.advanceEpoch(trio) for lane := range trio.Current.Committee().Lanes().All() { - require.Contains(t, i.blocks, lane) + require.Contains(t, i.lanes, lane) } } @@ -945,12 +942,12 @@ func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { epoch0Lane = l break } - require.Contains(t, i.blocks, epoch0Lane, "epoch0 lane missing before reweight") + require.Contains(t, i.lanes, epoch0Lane, "epoch0 lane missing before reweight") // trio1: Prev=epoch0, Current=epoch1, Next=epoch2 trio1 := utils.OrPanic1(registry.TrioAt(epoch.EpochLength)) i.advanceEpoch(trio1) // Epoch0 lane is now in Prev — must be retained for boundary QC collection. - require.Contains(t, i.blocks, epoch0Lane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") + require.Contains(t, i.lanes, epoch0Lane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 743ea26d52..9bdcf8411b 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -445,8 +445,8 @@ func (s *State) PushAppQC(appQC *types.AppQC, commitQC *types.CommitQC) error { // NextBlock returns the index of the next missing block in local storage for the given lane. func (s *State) NextBlock(lane types.LaneID) types.BlockNumber { for inner := range s.inner.Lock() { - if l, ok := inner.blocks[lane]; ok { - return l.next + if ls, ok := inner.lanes[lane]; ok { + return ls.blocks.next } } return 0 @@ -457,10 +457,11 @@ func (s *State) NextBlock(lane types.LaneID) types.BlockNumber { // Returns ErrPruned if the block has been already pruned. func (s *State) Block(ctx context.Context, lane types.LaneID, n types.BlockNumber) (*types.Signed[*types.LaneProposal], error) { for inner, ctrl := range s.inner.Lock() { - q, ok := inner.blocks[lane] + ls, ok := inner.lanes[lane] if !ok { return nil, ErrBadLane } + q := ls.blocks if err := ctrl.WaitUntil(ctx, func() bool { return n < q.next }); err != nil { return nil, err } @@ -488,12 +489,13 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos return fmt.Errorf("block.Verify(): %w", err) } for inner, ctrl := range s.inner.Lock() { - q, ok := inner.blocks[h.Lane()] + ls, ok := inner.lanes[h.Lane()] if !ok { return ErrBadLane } + q := ls.blocks if err := ctrl.WaitUntil(ctx, func() bool { - return h.BlockNumber() <= min(q.next, inner.persistedBlockStart[h.Lane()]+BlocksPerLane-1) + return h.BlockNumber() <= min(q.next, ls.persistedBlockStart+BlocksPerLane-1) }); err != nil { return err } @@ -541,12 +543,13 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote } h := vote.Msg().Header() for inner, ctrl := range s.inner.Lock() { - q, ok := inner.votes[h.Lane()] + ls, ok := inner.lanes[h.Lane()] if !ok { return ErrBadLane } + q := ls.votes if err := ctrl.WaitUntil(ctx, func() bool { - return h.BlockNumber() < inner.persistedBlockStart[h.Lane()]+BlocksPerLane + return h.BlockNumber() < ls.persistedBlockStart+BlocksPerLane }); err != nil { return err } @@ -575,7 +578,7 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc want := lr.LastHash() headers := make([]*types.BlockHeader, lr.Next()-lr.First()) for inner, ctrl := range s.inner.Lock() { - q := inner.votes[lr.Lane()] + q := inner.lanes[lr.Lane()].votes for i := range headers { n := lr.Next() - types.BlockNumber(i) - 1 //nolint:gosec // i is bounded by len(headers) which is a small block range; no overflow risk for { @@ -639,7 +642,7 @@ func (s *State) WaitForLocalCapacity(ctx context.Context, toProduce types.BlockN lane := s.key.Public() for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { - return toProduce < inner.persistedBlockStart[lane]+BlocksPerLane + return toProduce < inner.lanes[lane].persistedBlockStart+BlocksPerLane }); err != nil { return err } @@ -687,11 +690,12 @@ func (s *State) produceLocalBlock(n types.BlockNumber, key types.SecretKey, payl lane := key.Public() var result *types.Signed[*types.LaneProposal] for inner, ctrl := range s.inner.Lock() { - q, ok := inner.blocks[lane] + ls, ok := inner.lanes[lane] if !ok { return nil, ErrBadLane } - if n >= inner.persistedBlockStart[lane]+BlocksPerLane { + q := ls.blocks + if n >= ls.persistedBlockStart+BlocksPerLane { return nil, fmt.Errorf("lane full") } if q.next != n { @@ -739,10 +743,11 @@ func (s *State) Run(ctx context.Context) error { for inner := range s.inner.Lock() { for lane := range c.Lanes().All() { // Missing lane queue ⇒ skip (not yet created / future expiry). - q, ok := inner.blocks[lane] + ls, ok := inner.lanes[lane] if !ok { continue } + q := ls.blocks lr := qc.QC().LaneRange(lane) for n := lr.First(); n < lr.Next(); n++ { // We are not expected to have all the blocks locally - only the available ones. @@ -859,10 +864,10 @@ type persistBatch struct { // waiters that are gated on persistedBlockStart + BlocksPerLane. func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { for inner, ctrl := range s.inner.Lock() { - for lane := range inner.blocks { + for lane, ls := range inner.lanes { start := commitQC.LaneRange(lane).First() - if start > inner.persistedBlockStart[lane] { - inner.persistedBlockStart[lane] = start + if start > ls.persistedBlockStart { + ls.persistedBlockStart = start } } ctrl.Updated() @@ -875,11 +880,11 @@ func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { // callers (acquires s.inner lock internally). func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { for inner, ctrl := range s.inner.Lock() { - // Skip if blocks[lane] is gone; nextBlockToPersist may be lazily empty. - if _, ok := inner.blocks[lane]; !ok { + ls, ok := inner.lanes[lane] + if !ok { return } - inner.nextBlockToPersist[lane] = next + ls.nextBlockToPersist = next ctrl.Updated() } } @@ -908,8 +913,8 @@ func (s *State) collectPersistBatch(ctx context.Context, lastPersistedAppQCNext if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { return true } - for lane, q := range inner.blocks { - if inner.nextBlockToPersist[lane] < q.next { + for _, ls := range inner.lanes { + if ls.nextBlockToPersist < ls.blocks.next { return true } } @@ -917,10 +922,10 @@ func (s *State) collectPersistBatch(ctx context.Context, lastPersistedAppQCNext }); err != nil { return b, err } - for lane, q := range inner.blocks { - start := max(inner.nextBlockToPersist[lane], q.first) - for n := start; n < q.next; n++ { - b.blocks = append(b.blocks, q.q[n]) + for _, ls := range inner.lanes { + start := max(ls.nextBlockToPersist, ls.blocks.first) + for n := start; n < ls.blocks.next; n++ { + b.blocks = append(b.blocks, ls.blocks.q[n]) } } commitQCNext = max(commitQCNext, inner.commitQCs.first) diff --git a/sei-tendermint/internal/autobahn/avail/subscriptions.go b/sei-tendermint/internal/autobahn/avail/subscriptions.go index f27fa30e4a..944bfaf8b5 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions.go @@ -47,10 +47,10 @@ func (r *LaneVotesRecv) RecvBatch(ctx context.Context) ([]*types.Signed[*types.L var batch []*types.BlockHeader for inner, ctrl := range r.state.inner.Lock() { for { - for lane, bq := range inner.blocks { - upperBound := min(bq.next, inner.nextBlockToPersist[lane]) - for i := max(bq.first, r.next[lane]); i < upperBound; i++ { - batch = append(batch, bq.q[i].Msg().Block().Header()) + for lane, ls := range inner.lanes { + upperBound := min(ls.blocks.next, ls.nextBlockToPersist) + for i := max(ls.blocks.first, r.next[lane]); i < upperBound; i++ { + batch = append(batch, ls.blocks.q[i].Msg().Block().Header()) } r.next[lane] = upperBound } From 41fe6f6eee7bd4eab96e188254810c05a7d200d5 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 15:48:11 -0700 Subject: [PATCH 45/98] fix(autobahn): wait for CommitQC before resolving AppVote epoch Future AppVotes block until their CommitQC advances the window; stale votes still drop. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 8 ++++---- .../internal/autobahn/avail/state_test.go | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 9bdcf8411b..747e7ec096 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -351,6 +351,10 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // PushAppVote pushes an AppVote to the state. func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]) error { idx := v.Msg().Proposal().RoadIndex() + // A vote may arrive before its CommitQC advances the epoch window. + if err := s.waitForCommitQC(ctx, idx); err != nil { + return err + } ep, err := s.epochTrio.Load().EpochForRoad(idx) if err != nil { // Out-of-window ⇒ stale; drop (do not tear down the peer). @@ -362,10 +366,6 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if err := v.VerifySig(committee); err != nil { return fmt.Errorf("v.VerifySig(): %w", err) } - // Wait for the corresponding commitQC. - if err := s.waitForCommitQC(ctx, idx); err != nil { - return err - } for inner, ctrl := range s.inner.Lock() { // Early exit if not useful (we collect <=1 AppQC per road index). if idx < types.NextOpt(inner.latestAppQC) { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 24b41c4651..4c9da4c4cc 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -888,6 +888,26 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { require.False(t, state.LastAppQC().IsPresent()) } +func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + registry.AdvanceIfNeeded(0) + future := utils.OrPanic1(registry.EpochAt(2 * epoch.EpochLength)) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + proposal := types.NewAppProposal( + future.FirstBlock(), future.RoadRange().First, types.GenAppHash(rng), future.EpochIndex()) + vote := types.Sign(keys[0], types.NewAppVote(proposal)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) +} + // TestPushAppQCPreviousEpoch verifies that an AppQC whose road index falls in // epoch N-1 is accepted when the registry is seeded at epoch N. This exercises // the path where a late AppQC arrives after an epoch boundary has been crossed. From 6b11740fcc4c85ecb4fd2d81f4c945df4b4320f7 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 16:04:06 -0700 Subject: [PATCH 46/98] fix(autobahn): block PushAppQC until road is in epoch window Backpressure peer streams while catching up; drop only when the road is stale. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner_test.go | 8 ++-- .../internal/autobahn/avail/state.go | 36 +++++++++++++--- .../internal/autobahn/avail/state_test.go | 43 +++++++++++++++++-- .../internal/autobahn/avail/testonly.go | 2 +- sei-tendermint/internal/p2p/giga/avail.go | 2 +- 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 86be648c64..d8d980b962 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -41,10 +41,10 @@ func TestPruneMismatchedIndices(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - require.Error(t, state.PushAppQC(makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") - require.Error(t, state.PushAppQC(makeAppQC(qc1, qc0), qc1), "good range, bad index should fail") - require.Error(t, state.PushAppQC(makeAppQC(qc0, qc1), qc1), "bad range, good index should fail") - require.NoError(t, state.PushAppQC(makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc1, qc0), qc1), "good range, bad index should fail") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc0, qc1), qc1), "bad range, good index should fail") + require.NoError(t, state.PushAppQC(t.Context(), makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") t.Logf("test inner.prune") ds = utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 747e7ec096..ef948be7b2 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -224,6 +224,29 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error return err } +// waitEpochForRoad blocks until roadIdx is in Prev|Current|Next, backpressuring +// callers (e.g. peer streams) until this node catches up. +// Returns None if the road has fallen behind the window (stale). +func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { + trio, err := s.epochTrio.Wait(ctx, func(trio types.EpochTrio) bool { + if _, err := trio.EpochForRoad(roadIdx); err == nil { + return true + } + first := trio.Current.RoadRange().First + if prev, ok := trio.Prev.Get(); ok { + first = prev.RoadRange().First + } + return roadIdx < first + }) + if err != nil { + return utils.None[*types.Epoch](), err + } + if ep, err := trio.EpochForRoad(roadIdx); err == nil { + return utils.Some(ep), nil + } + return utils.None[*types.Epoch](), nil +} + // waitForAppQCEpoch blocks until latest AppQC is from epochIdx or later. func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex) error { for inner, ctrl := range s.inner.Lock() { @@ -357,7 +380,6 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] } ep, err := s.epochTrio.Load().EpochForRoad(idx) if err != nil { - // Out-of-window ⇒ stale; drop (do not tear down the peer). logger.Info("dropping stale AppVote: road outside epoch window", slog.Uint64("road", uint64(idx)), "err", err) return nil @@ -399,18 +421,22 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] // PushAppQC pushes an AppQC to the state. It requires a corresponding CommitQC // as a justification. -func (s *State) PushAppQC(appQC *types.AppQC, commitQC *types.CommitQC) error { +func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *types.CommitQC) error { // Check whether it is needed before verifying. for inner := range s.inner.Lock() { if types.NextOpt(inner.latestAppQC) > appQC.Proposal().RoadIndex() { return nil } } - ep, err := s.epochTrio.Load().EpochForRoad(commitQC.Proposal().Index()) + idx := commitQC.Proposal().Index() + epOpt, err := s.waitEpochForRoad(ctx, idx) if err != nil { - // Out-of-window ⇒ stale; drop (do not tear down the peer). + return err + } + ep, ok := epOpt.Get() + if !ok { logger.Info("dropping stale AppQC: road outside epoch window", - slog.Uint64("road", uint64(commitQC.Proposal().Index())), "err", err) + slog.Uint64("road", uint64(idx))) return nil } if err := appQC.Verify(ep.Committee()); err != nil { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 4c9da4c4cc..c791be2a34 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -372,7 +372,7 @@ func TestStateMismatchedQCs(t *testing.T) { appProposal1 := types.NewAppProposal(initialBlock, 1, types.GenAppHash(rng), 0) appQC1 := types.NewAppQC(makeAppVotes(keys, appProposal1)) - err := state.PushAppQC(appQC1, qc0) + err := state.PushAppQC(t.Context(), appQC1, qc0) require.Error(err) }) } @@ -408,7 +408,7 @@ func TestWaitForAppQCEpoch(t *testing.T) { done := make(chan error, 1) go func() { done <- state.waitForAppQCEpoch(ctx, 0) }() - require.NoError(t, state.PushAppQC(appQC, qc0)) + require.NoError(t, state.PushAppQC(ctx, appQC, qc0)) require.NoError(t, <-done) require.NoError(t, state.waitForAppQCEpoch(ctx, 0)) @@ -884,7 +884,7 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { inner.epochTrio.Store(farTrio) } - require.NoError(t, state.PushAppQC(appQC, qc0)) + require.NoError(t, state.PushAppQC(t.Context(), appQC, qc0)) require.False(t, state.LastAppQC().IsPresent()) } @@ -908,6 +908,41 @@ func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) } +func TestWaitEpochForRoadFutureBlocks(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = state.waitEpochForRoad(ctx, 2*epoch.EpochLength) + require.ErrorIs(t, err, context.Canceled) +} + +func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + for _, road := range []types.RoadIndex{0, epoch.EpochLength, 2 * epoch.EpochLength} { + registry.AdvanceIfNeeded(road) + } + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + farTrio := utils.OrPanic1(registry.TrioAt(2 * epoch.EpochLength)) + for inner := range state.inner.Lock() { + inner.epochTrio.Store(farTrio) + } + + ep, err := state.waitEpochForRoad(t.Context(), 0) + require.NoError(t, err) + require.False(t, ep.IsPresent()) +} + // TestPushAppQCPreviousEpoch verifies that an AppQC whose road index falls in // epoch N-1 is accepted when the registry is seeded at epoch N. This exercises // the path where a late AppQC arrives after an epoch boundary has been crossed. @@ -943,7 +978,7 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { // Push the epoch-N CommitQC first so the state has it, then push the late AppQC. require.NoError(t, state.PushCommitQC(t.Context(), commitQC)) - require.NoError(t, state.PushAppQC(appQC, commitQC), + require.NoError(t, state.PushAppQC(t.Context(), appQC, commitQC), "AppQC from epoch N-1 should be accepted when registry has epoch N-1 registered") } diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index f17a9f814c..500c779055 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -78,7 +78,7 @@ func RunTestNetwork(ctx context.Context, states []*State) error { } next = appQC.Next() for _, to := range states { - if err := to.PushAppQC(appQC, commitQC); err != nil { + if err := to.PushAppQC(ctx, appQC, commitQC); err != nil { return err } } diff --git a/sei-tendermint/internal/p2p/giga/avail.go b/sei-tendermint/internal/p2p/giga/avail.go index d45b807f86..cf5c29c5e4 100644 --- a/sei-tendermint/internal/p2p/giga/avail.go +++ b/sei-tendermint/internal/p2p/giga/avail.go @@ -245,7 +245,7 @@ func (x *Service) clientStreamAppQCs(ctx context.Context, c rpc.Client[API]) err if err != nil { return fmt.Errorf("StreamAppQCsRespConv.Decode(): %w", err) } - if err := x.validatorState().Avail().PushAppQC(msg.AppQC, msg.CommitQC); err != nil { + if err := x.validatorState().Avail().PushAppQC(ctx, msg.AppQC, msg.CommitQC); err != nil { return fmt.Errorf("s.PushFirstCommitQC(): %w", err) } } From 883d9a943ea6c4d09c221078150336a75966d082 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 16:10:35 -0700 Subject: [PATCH 47/98] refactor(autobahn): cache lane vote weight for Current only Drop per-epoch byHash maps; reweight from byKey on epoch advance. Co-authored-by: Cursor --- .../internal/autobahn/avail/block_votes.go | 92 +++++++------- .../autobahn/avail/block_votes_test.go | 116 +++++------------- .../internal/autobahn/avail/inner.go | 8 +- .../internal/autobahn/avail/state.go | 27 +--- 4 files changed, 84 insertions(+), 159 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 771da202b1..2b76bb15a2 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -5,15 +5,21 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// laneVoteSet is weighted votes for one (block hash, epoch). -// qc is set once when weight first reaches quorum. +// laneVoteSet caches Current-epoch weight for one hash. +// header survives reweight; qc is set at quorum. type laneVoteSet struct { weight uint64 votes []*types.Signed[*types.LaneVote] qc *types.LaneQC + header *types.BlockHeader +} + +func (s *laneVoteSet) reset() { + s.weight = 0 + s.votes = s.votes[:0] + s.qc = nil } -// add credits vote. Returns the newly formed LaneQC iff this vote crosses quorum. func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { if s.qc != nil { return utils.None[*types.LaneQC]() @@ -27,77 +33,63 @@ func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneV return utils.Some(s.qc) } -// blockVotes is per-(lane, height) vote state. Weight is tracked per epoch, so -// an epoch advance does not reweight existing sets. +// blockVotes weights Current only; reweight on epoch advance. type blockVotes struct { - // byKey: one vote per signer; source for applyEpoch backfill. - byKey map[types.PublicKey]*types.Signed[*types.LaneVote] - // byHash: per-hash, per-epoch weighted sets (lazy; present ⇒ non-empty). - byHash map[types.BlockHeaderHash]map[types.EpochIndex]*laneVoteSet + byKey map[types.PublicKey]*types.Signed[*types.LaneVote] + byHash map[types.BlockHeaderHash]*laneVoteSet } func newBlockVotes() blockVotes { return blockVotes{ byKey: map[types.PublicKey]*types.Signed[*types.LaneVote]{}, - byHash: map[types.BlockHeaderHash]map[types.EpochIndex]*laneVoteSet{}, + byHash: map[types.BlockHeaderHash]*laneVoteSet{}, } } -// pushVote credits vote into each eps[i] where the signer has weight. -// Contract: eps[0] is Current, later entries are Next (and only those). -// Stores a LaneQC on any epoch that newly reaches quorum, but returns only the -// newly formed Current QC. Caller notifies when present. -func (bv blockVotes) pushVote(eps []*types.Epoch, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { +// pushVote stores vote under ep; zero-weight signers stay in byKey for reweight. +func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { k := vote.Key() if _, ok := bv.byKey[k]; ok { return utils.None[*types.LaneQC]() } bv.byKey[k] = vote - current := utils.None[*types.LaneQC]() - for i, ep := range eps { - formed := bv.credit(ep, vote) - if i == 0 { - current = formed - } - } - return current -} - -// credit adds vote under ep; returns a newly formed LaneQC if this vote crosses quorum. -func (bv blockVotes) credit(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { - c := ep.Committee() - w := c.Weight(vote.Key()) - if w == 0 { - return utils.None[*types.LaneQC]() - } h := vote.Msg().Header().Hash() - byEpoch, ok := bv.byHash[h] + set, ok := bv.byHash[h] if !ok { - byEpoch = map[types.EpochIndex]*laneVoteSet{} - bv.byHash[h] = byEpoch + set = &laneVoteSet{header: vote.Msg().Header()} + bv.byHash[h] = set } - set := byEpoch[ep.EpochIndex()] - if set == nil { - set = &laneVoteSet{} - byEpoch[ep.EpochIndex()] = set + w := ep.Committee().Weight(k) + if w == 0 { + return utils.None[*types.LaneQC]() } - return set.add(w, c.LaneQuorum(), vote) + return set.add(w, ep.Committee().LaneQuorum(), vote) } -// applyEpoch backfills ep from byKey when ep newly enters the window as Next. -// ep's sets are empty beforehand, so this does not double-count. -func (bv blockVotes) applyEpoch(ep *types.Epoch) { - for _, vote := range bv.byKey { - bv.credit(ep, vote) +// reweight recomputes byHash from byKey; returns true if any new quorum. +func (bv blockVotes) reweight(newEpoch *types.Epoch) bool { + c := newEpoch.Committee() + for _, set := range bv.byHash { + set.reset() + } + quorumReached := false + for k, vote := range bv.byKey { + w := c.Weight(k) + if w == 0 { + continue + } + set := bv.byHash[vote.Msg().Header().Hash()] + if set.add(w, c.LaneQuorum(), vote).IsPresent() { + quorumReached = true + } } + return quorumReached } -// laneQC returns a stored LaneQC for ep, if any hash has one. -func (bv blockVotes) laneQC(ep *types.Epoch) utils.Option[*types.LaneQC] { - epIdx := ep.EpochIndex() - for _, byEpoch := range bv.byHash { - if set, ok := byEpoch[epIdx]; ok && set.qc != nil { +func (bv blockVotes) laneQC() utils.Option[*types.LaneQC] { + for _, set := range bv.byHash { + if set.qc != nil { return utils.Some(set.qc) } } diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 8d56037b35..e75175495e 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -47,107 +47,58 @@ func TestLaneVoteSet_Add(t *testing.T) { require.NotNil(t, heavy.qc) } -func TestPushVote_ZeroWeightLeavesNoByHashEntry(t *testing.T) { +func TestPushVote_ZeroWeightKeptForReweight(t *testing.T) { rng := utils.TestRng() keyA := types.GenSecretKey(rng) - keyZ := types.GenSecretKey(rng) // in neither epoch + keyZ := types.GenSecretKey(rng) ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) - ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{keyA.Public(): 1}) lane := keyA.Public() header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() bv := newBlockVotes() - require.False(t, bv.pushVote([]*types.Epoch{ep0, ep1}, types.Sign(keyZ, types.NewLaneVote(header))).IsPresent()) - require.Contains(t, bv.byKey, keyZ.Public(), "vote retained in byKey for later back-fill") - require.NotContains(t, bv.byHash, header.Hash(), "zero-weight vote must not create a byHash entry") + require.False(t, bv.pushVote(ep0, types.Sign(keyZ, types.NewLaneVote(header))).IsPresent()) + require.Contains(t, bv.byKey, keyZ.Public()) + set := bv.byHash[header.Hash()] + require.NotNil(t, set) + require.Equal(t, header, set.header) + require.Equal(t, uint64(0), set.weight) + require.Empty(t, set.votes) } -func TestPushVote_AccumulatesPerEpoch(t *testing.T) { +func TestPushVote_CurrentOnly(t *testing.T) { rng := utils.TestRng() - - keyA := types.GenSecretKey(rng) // current epoch only - keyE := types.GenSecretKey(rng) // next epoch only + keyA := types.GenSecretKey(rng) + keyE := types.GenSecretKey(rng) // next-only ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{keyE.Public(): 1}) - eps := []*types.Epoch{ep0, ep1} lane := keyA.Public() - block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) - header := block.Header() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() h := header.Hash() bv := newBlockVotes() - - // E reaches Next quorum only: stored on set, but return is None (Current-only). - require.False(t, bv.pushVote(eps, types.Sign(keyE, types.NewLaneVote(header))).IsPresent(), - "Next-only quorum must not return a QC") + require.False(t, bv.pushVote(ep0, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) require.Contains(t, bv.byKey, keyE.Public()) + require.Equal(t, uint64(0), bv.byHash[h].weight) - byEpoch := bv.byHash[h] - _, inEp0 := byEpoch[0] - require.False(t, inEp0, "E has no weight in ep0; the ep0 set must not be created") - set1 := byEpoch[1] - require.NotNil(t, set1) - require.NotNil(t, set1.qc, "Next still stores its LaneQC") - require.Len(t, set1.votes, 1) - require.Equal(t, keyE.Public(), set1.votes[0].Key()) - require.Equal(t, header, set1.votes[0].Msg().Header()) - - // A reaches Current quorum ⇒ returned for notify. - currentQC, ok := bv.pushVote(eps, types.Sign(keyA, types.NewLaneVote(header))).Get() - require.True(t, ok) - set0 := byEpoch[0] - require.NotNil(t, set0) - require.Equal(t, currentQC, set0.qc) - require.Len(t, set0.votes, 1) - require.Equal(t, keyA.Public(), set0.votes[0].Key()) - require.Len(t, set1.votes, 1, "A's vote does not leak into the ep1 set") - - qc, ok := bv.laneQC(ep0).Get() - require.True(t, ok) - require.Equal(t, currentQC, qc) - qc, ok = bv.laneQC(ep1).Get() + qc, ok := bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).Get() require.True(t, ok) - require.Equal(t, set1.qc, qc) -} - -func TestApplyEpoch_BackfillsFromStoredVotes(t *testing.T) { - rng := utils.TestRng() - keyA := types.GenSecretKey(rng) - keyB := types.GenSecretKey(rng) - keyC := types.GenSecretKey(rng) - keyD := types.GenSecretKey(rng) - weights := map[types.PublicKey]uint64{ - keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, - } - ep0 := makeVoteEpoch(0, weights) - ep1 := makeVoteEpoch(1, weights) - ep2 := makeVoteEpoch(2, weights) + require.Equal(t, qc, bv.byHash[h].qc) - lane := keyA.Public() - block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) - header := block.Header() - h := header.Hash() - - bv := newBlockVotes() - bv.pushVote([]*types.Epoch{ep0, ep1}, types.Sign(keyA, types.NewLaneVote(header))) - _, hasEp2 := bv.byHash[h][2] - require.False(t, hasEp2, "ep2 not credited at push time") - - bv.applyEpoch(ep2) - set2 := bv.byHash[h][2] - require.NotNil(t, set2, "ep2 set seeded on entry") - require.Len(t, set2.votes, 1) - require.Equal(t, keyA.Public(), set2.votes[0].Key()) - require.Equal(t, uint64(1), set2.weight) - require.Nil(t, set2.qc, "quorum is 2; backfill of one vote must not form a QC") - - bv.pushVote([]*types.Epoch{ep1, ep2}, types.Sign(keyB, types.NewLaneVote(header))) - require.Len(t, set2.votes, 2, "B added to ep2 after entry") - require.NotNil(t, set2.qc) + got, ok := bv.laneQC().Get() + require.True(t, ok) + require.Equal(t, qc, got) + + // After advance, E is counted under ep1. + require.True(t, bv.reweight(ep1)) + require.Equal(t, uint64(1), bv.byHash[h].weight) + require.Len(t, bv.byHash[h].votes, 1) + require.Equal(t, keyE.Public(), bv.byHash[h].votes[0].Key()) + require.NotNil(t, bv.byHash[h].qc) + require.Equal(t, header, bv.byHash[h].header, "header preserved across reweight") } func TestPushVote_DedupsSigner(t *testing.T) { @@ -159,20 +110,19 @@ func TestPushVote_DedupsSigner(t *testing.T) { weights := map[types.PublicKey]uint64{ keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, } - eps := []*types.Epoch{makeVoteEpoch(0, weights), makeVoteEpoch(1, weights)} + ep := makeVoteEpoch(0, weights) lane := keyA.Public() - block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) - header := block.Header() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() bv := newBlockVotes() vote := types.Sign(keyA, types.NewLaneVote(header)) - require.False(t, bv.pushVote(eps, vote).IsPresent(), "one of four validators is below quorum (2)") - set := bv.byHash[header.Hash()][0] + require.False(t, bv.pushVote(ep, vote).IsPresent(), "one of four validators is below quorum (2)") + set := bv.byHash[header.Hash()] require.Equal(t, uint64(1), set.weight) - require.False(t, bv.pushVote(eps, vote).IsPresent()) + require.False(t, bv.pushVote(ep, vote).IsPresent()) require.Equal(t, uint64(1), set.weight, "duplicate vote must not double-count") require.Len(t, set.votes, 1) } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 004a4af618..0cd2b352c5 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -165,9 +165,8 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt return i, nil } -// laneQC returns a stored LaneQC for (lane, n) under ep, if any. -func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber, ep *types.Epoch) utils.Option[*types.LaneQC] { - return i.lanes[lane].votes.q[n].laneQC(ep) +func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { + return i.lanes[lane].votes.q[n].laneQC() } // advanceEpoch applies nextTrio at an epoch boundary. @@ -177,10 +176,9 @@ func (i *inner) advanceEpoch(nextTrio types.EpochTrio) { for lane := range nextTrio.CurrentAndNextLanes() { i.getOrInsertLane(lane) } - // Seed the newly-entering Next epoch's vote sets from votes already stored. for _, ls := range i.lanes { for n := ls.votes.first; n < ls.votes.next; n++ { - ls.votes.q[n].applyEpoch(nextTrio.Next) + ls.votes.q[n].reweight(nextTrio.Current) } } i.epochTrio.Store(nextTrio) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index ef948be7b2..f10bb5bf1e 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -360,7 +360,6 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { } if nextTrio != nil { inner.advanceEpoch(*nextTrio) - // Always wake: next-epoch lane quorum may already be silent in pushVote. } inner.commitQCs.pushBack(qc) metrics.ObserveCommitQC(qc) @@ -585,10 +584,8 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - // Credit under a fresh trio load. Safe if the window advanced since - // VerifyInWindow: weight is re-derived per epoch; trio only moves forward. trio := s.epochTrio.Load() - if q.q[h.BlockNumber()].pushVote([]*types.Epoch{trio.Current, trio.Next}, vote).IsPresent() { + if q.q[h.BlockNumber()].pushVote(trio.Current, vote).IsPresent() { ctrl.Updated() } } @@ -612,22 +609,10 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc if q.first > lr.First() { return nil, data.ErrPruned } - // Check if we have the header. Every per-epoch set for a hash - // is non-empty, and all its votes carry that hash's header, so - // votes[0] of any set recovers it. - if byEpoch, ok := q.q[n].byHash[want]; ok { - var h *types.BlockHeader - for _, set := range byEpoch { - if len(set.votes) > 0 { - h = set.votes[0].Msg().Header() - break - } - } - if h != nil { - want = h.ParentHash() - headers[len(headers)-i-1] = h - break - } + if set, ok := q.q[n].byHash[want]; ok && set.header != nil { + want = set.header.ParentHash() + headers[len(headers)-i-1] = set.header + break } // Otherwise, wait. if err := ctrl.Wait(ctx); err != nil { @@ -687,7 +672,7 @@ func (s *State) WaitForLaneQCs( for lane := range ep.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { - if qc, ok := inner.laneQC(lane, first+i, ep).Get(); ok { + if qc, ok := inner.laneQC(lane, first+i).Get(); ok { laneQCs[lane] = qc } else { break From d3d4e8bde1df07ad9faa89b496bfa558dc28ee75 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 16:12:21 -0700 Subject: [PATCH 48/98] fix(autobahn): require prune anchor when restarting past epoch 0 newInner errors if Current.EpochIndex > 0 and no prune anchor is present. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/inner.go | 6 +++++- .../internal/autobahn/avail/inner_test.go | 13 +++++++++++++ .../internal/autobahn/avail/state_test.go | 7 ++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 0cd2b352c5..480d21cbe3 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -91,7 +91,9 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt } l, ok := loaded.Get() if !ok { - // Fresh node: appVotes start at the operating epoch's first block. + if startEpochTrio.Current.EpochIndex() > 0 { + return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochTrio.Current.EpochIndex()) + } i.appVotes.prune(startEpochTrio.Current.FirstBlock()) return i, nil } @@ -115,6 +117,8 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt // No anchor: don't raise appVotes to a tip epoch's FirstBlock — live // advanceEpoch also leaves appVotes at the genesis floor. i.appVotes.prune(startEpochTrio.Current.FirstBlock()) + } else { + return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochTrio.Current.EpochIndex()) } // Restore persisted CommitQCs. prune() may have already pushed the diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index d8d980b962..215790b5f8 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -114,6 +114,19 @@ func TestNewInnerLoadedNoAnchor(t *testing.T) { require.Equal(t, registry.FirstBlock(), i.appVotes.first) } +func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { + rng := utils.TestRng() + registry, _, _ := epoch.GenRegistry(rng, 4) + registry.AdvanceIfNeeded(0) + trio := utils.OrPanic1(registry.TrioAt(epoch.EpochLength)) + require.Equal(t, types.EpochIndex(1), trio.Current.EpochIndex()) + + _, err := newInner(trio, utils.Some(&loadedAvailState{})) + require.Error(t, err) + _, err = newInner(trio, utils.None[*loadedAvailState]()) + require.Error(t, err) +} + func TestNewInnerLoadedBlocksContiguous(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 4) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index c791be2a34..6489fb1a6d 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1003,11 +1003,8 @@ func TestRestartTrioFromCommitTipNeedsNPlus2(t *testing.T) { require.Equal(t, types.EpochIndex(1), tipTrio.Current.EpochIndex()) require.Equal(t, types.EpochIndex(2), tipTrio.Next.EpochIndex()) - inner, err := newInner(tipTrio, utils.None[*loadedAvailState]()) - require.NoError(t, err) - got := inner.epochTrio.Load() - require.Equal(t, types.EpochIndex(1), got.Current.EpochIndex()) - require.Equal(t, types.EpochIndex(2), got.Next.EpochIndex()) + _, err = newInner(tipTrio, utils.None[*loadedAvailState]()) + require.Error(t, err, "epoch > 0 requires a prune anchor") } func TestNextCommitQC(t *testing.T) { From 50f5ea1ccb3b684ad9b7eb33040c42818cd5c484 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 16:15:59 -0700 Subject: [PATCH 49/98] refactor(autobahn): WaitForLaneQCs returns Current epoch instead of taking it Align with Current-only LaneQC weighting: drop the ep argument and let the caller verify the returned epoch matches the view it intends to propose in. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 14 ++++++++------ .../internal/autobahn/avail/state_test.go | 9 ++++++--- .../internal/autobahn/consensus/state.go | 7 ++++++- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index f10bb5bf1e..26f8558991 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -661,14 +661,16 @@ func (s *State) WaitForLocalCapacity(ctx context.Context, toProduce types.BlockN return nil } -// WaitForLaneQCs waits until there is at least 1 LaneQC (for the given epoch) -// with a block not finalized by prev. +// WaitForLaneQCs waits until there is at least 1 LaneQC in the Current epoch +// with a block not finalized by prev. Returns the Current epoch alongside the +// QCs so the caller can verify it matches the epoch it intends to propose in. func (s *State) WaitForLaneQCs( - ctx context.Context, ep *types.Epoch, prev utils.Option[*types.CommitQC], -) (map[types.LaneID]*types.LaneQC, error) { + ctx context.Context, prev utils.Option[*types.CommitQC], +) (map[types.LaneID]*types.LaneQC, *types.Epoch, error) { for inner, ctrl := range s.inner.Lock() { laneQCs := map[types.LaneID]*types.LaneQC{} for { + ep := inner.epochTrio.Load().Current for lane := range ep.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { @@ -680,10 +682,10 @@ func (s *State) WaitForLaneQCs( } } if len(laneQCs) > 0 { - return laneQCs, nil + return laneQCs, ep, nil } if err := ctrl.Wait(ctx); err != nil { - return nil, err + return nil, nil, err } } } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 6489fb1a6d..45d924a7ac 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -142,7 +142,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { } t.Logf("Push a commit QC.") - laneQCs, err := state.WaitForLaneQCs(ctx, registry.LatestEpoch(), prev) + laneQCs, _, err := state.WaitForLaneQCs(ctx, prev) if err != nil { return fmt.Errorf("state.WaitForNewLaneQCs(): %w", err) } @@ -263,7 +263,7 @@ func TestStateRestartFromPersisted(t *testing.T) { } } - laneQCs, err := state.WaitForLaneQCs(ctx, registry.LatestEpoch(), prev) + laneQCs, _, err := state.WaitForLaneQCs(ctx, prev) if err != nil { return fmt.Errorf("WaitForLaneQCs: %w", err) } @@ -841,10 +841,13 @@ func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { } } - laneQCs, err := state.WaitForLaneQCs(ctx, ep, utils.None[*types.CommitQC]()) + laneQCs, gotEp, err := state.WaitForLaneQCs(ctx, utils.None[*types.CommitQC]()) if err != nil { return err } + if gotEp.EpochIndex() != ep.EpochIndex() { + return fmt.Errorf("WaitForLaneQCs returned epoch %d, want %d", gotEp.EpochIndex(), ep.EpochIndex()) + } for lane := range laneQCs { if !ep.Committee().HasLane(lane) { return fmt.Errorf("WaitForLaneQCs returned lane %v outside committee", lane) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index f0626af373..a06d2c1697 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -242,10 +242,15 @@ func (s *State) runPropose(ctx context.Context) error { return nil } // Wait for laneQCs. - laneQCsMap, err := s.avail.WaitForLaneQCs(ctx, vs.Epoch, vs.CommitQC) + laneQCsMap, ep, err := s.avail.WaitForLaneQCs(ctx, vs.CommitQC) if err != nil { return fmt.Errorf("s.avail.WaitForLaneQCs(): %w", err) } + // The avail window may have advanced past the epoch we intend to + // propose in; skip and let the next view catch up. + if ep.EpochIndex() != vs.Epoch.EpochIndex() { + return nil + } // Construct a full proposal. fullProposal, err := types.NewProposal( s.cfg.Key, From 0448f7bc437198ab864ea9d5d5728038a2dba181 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 16:19:56 -0700 Subject: [PATCH 50/98] refactor(autobahn): collect PushQC blocks from QC headers Iterate FullCommitQC headers instead of the committee so block collection stays correct as committees change. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 26f8558991..26a06b3920 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -740,7 +740,7 @@ func (s *State) Run(ctx context.Context) error { // Task inserting FullCommitQCs and local blocks to data state. scope.SpawnNamed("s.data.PushQC", func() error { for n := types.RoadIndex(0); ; n = max(n+1, s.FirstCommitQC()) { - qc, ep, err := s.fullCommitQC(ctx, n) + qc, _, err := s.fullCommitQC(ctx, n) if err != nil { if errors.Is(err, data.ErrPruned) { continue @@ -748,27 +748,17 @@ func (s *State) Run(ctx context.Context) error { return err } - // Collect the blocks we have locally, using the same epoch - // fullCommitQC already resolved for this road (no second lookup, - // so no chance of the window racing past it here). - c := ep.Committee() + // Collect locally available blocks for the QC's headers. var blocks []*types.Block for inner := range s.inner.Lock() { - for lane := range c.Lanes().All() { - // Missing lane queue ⇒ skip (not yet created / future expiry). - ls, ok := inner.lanes[lane] + for _, h := range qc.Headers() { + ls, ok := inner.lanes[h.Lane()] if !ok { continue } - q := ls.blocks - lr := qc.QC().LaneRange(lane) - for n := lr.First(); n < lr.Next(); n++ { - // We are not expected to have all the blocks locally - only the available ones. - if b, ok := q.q[n]; ok { - // We don't need to check the blocks against the headers, - // as bad blocks will be filtered out by PushQC anyway. - blocks = append(blocks, b.Msg().Block()) - } + if b, ok := ls.blocks.q[h.BlockNumber()]; ok { + // No need to check against headers; PushQC filters mismatches. + blocks = append(blocks, b.Msg().Block()) } } } From 3438dc16329421d99350625d74aba43ae9c92022 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 16:21:01 -0700 Subject: [PATCH 51/98] docs(autobahn): note appVotes prune on CommitQC anchor, not advanceEpoch Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/inner.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 480d21cbe3..c40d0e8f76 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -174,6 +174,7 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ } // advanceEpoch applies nextTrio at an epoch boundary. +// appVotes are pruned on CommitQC anchor arrival (prune), not here. // // TODO(lane-expiry): do not delete old lanes here until epoch-scoped lane IDs exist. func (i *inner) advanceEpoch(nextTrio types.EpochTrio) { From 49d2e5c431019fe5212dd06e48826cd9d308d6d8 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 18:47:55 -0700 Subject: [PATCH 52/98] refactor(autobahn): rename EpochTrio files to EpochDuo Pure rename to preserve git history; content changes follow separately. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/{epoch_trio.go => epoch_duo.go} | 0 .../autobahn/types/{epoch_trio_test.go => epoch_duo_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename sei-tendermint/autobahn/types/{epoch_trio.go => epoch_duo.go} (100%) rename sei-tendermint/autobahn/types/{epoch_trio_test.go => epoch_duo_test.go} (100%) diff --git a/sei-tendermint/autobahn/types/epoch_trio.go b/sei-tendermint/autobahn/types/epoch_duo.go similarity index 100% rename from sei-tendermint/autobahn/types/epoch_trio.go rename to sei-tendermint/autobahn/types/epoch_duo.go diff --git a/sei-tendermint/autobahn/types/epoch_trio_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go similarity index 100% rename from sei-tendermint/autobahn/types/epoch_trio_test.go rename to sei-tendermint/autobahn/types/epoch_duo_test.go From afcd76a2495e3bc802b5fce5600b5e17385026a7 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 19:02:20 -0700 Subject: [PATCH 53/98] refactor(autobahn): replace EpochTrio with EpochDuo{Prev, Current} Drop the resident Next epoch: new-committee lane traffic is admitted only after CommitQC advances Current into the next epoch. CommitQC/block/vote verification is now Current-only; the registry seeds {N-1, N} and fetches the next epoch at the boundary via WaitForDuo (renamed from TrioAt/WaitForTrio). Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 55 ++---- .../autobahn/types/epoch_duo_test.go | 178 ++---------------- .../internal/autobahn/avail/inner.go | 31 +-- .../internal/autobahn/avail/inner_test.go | 95 +++++----- .../internal/autobahn/avail/state.go | 101 +++++----- .../internal/autobahn/avail/state_test.go | 49 +++-- .../internal/autobahn/consensus/inner.go | 4 +- .../internal/autobahn/data/state.go | 62 +++--- .../internal/autobahn/data/state_test.go | 10 +- .../internal/autobahn/epoch/registry.go | 80 ++++---- .../internal/autobahn/epoch/registry_test.go | 101 +++++----- .../internal/autobahn/epoch/testonly.go | 7 +- .../internal/p2p/giga_router_common.go | 6 +- .../internal/p2p/giga_router_validator.go | 2 +- 14 files changed, 294 insertions(+), 487 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 4d680da8d4..e69e247f37 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -6,22 +6,23 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// EpochTrio is a sliding window of up to three consecutive epochs. -// Current and Next are always set; Prev is absent only for epoch 0. -type EpochTrio struct { +// EpochDuo is a sliding window of up to two consecutive epochs. +// Current is always set; Prev is absent only for epoch 0. +// +// Next is intentionally not held: new-committee lane traffic is admitted only +// after CommitQC advances Current into the next epoch. +type EpochDuo struct { Prev utils.Option[*Epoch] // absent if Current is epoch 0 Current *Epoch - Next *Epoch } -// all is Current, Next, Prev — EpochForRoad prefers Current so an open-range -// Prev cannot shadow later epochs. -func (w EpochTrio) all() [3]utils.Option[*Epoch] { - return [3]utils.Option[*Epoch]{utils.Some(w.Current), utils.Some(w.Next), w.Prev} +func (w EpochDuo) all() [2]utils.Option[*Epoch] { + return [2]utils.Option[*Epoch]{utils.Some(w.Current), w.Prev} } // EpochForRoad returns the epoch whose road range contains roadIdx. -func (w EpochTrio) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { +// Prefers Current so an open-range Prev cannot shadow later epochs. +func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { for _, oep := range w.all() { if ep, ok := oep.Get(); ok && ep.RoadRange().Has(roadIdx) { return ep, nil @@ -30,42 +31,8 @@ func (w EpochTrio) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { return nil, fmt.Errorf("road %d not in window %v", roadIdx, w) } -// EpochForCommitRoad resolves the epoch for CommitQC ingest. -func (w EpochTrio) EpochForCommitRoad(roadIdx RoadIndex) (*Epoch, error) { - if w.Current.RoadRange().Has(roadIdx) { - return w.Current, nil - } - if w.Next.RoadRange().Has(roadIdx) { - return w.Next, nil - } - if roadIdx >= w.Next.RoadRange().Next { - panic(fmt.Sprintf("CommitQC road %d above Next %v: impossible after waitForCommitQC in-order tip", roadIdx, w)) - } - return nil, fmt.Errorf("CommitQC road %d before Current in %v", roadIdx, w) -} - -func (w EpochTrio) CurrentAndNextLanes() map[LaneID]struct{} { - lanes := make(map[LaneID]struct{}) - for _, ep := range [2]*Epoch{w.Current, w.Next} { - for lane := range ep.Committee().Lanes().All() { - lanes[lane] = struct{}{} - } - } - return lanes -} - -// VerifyInWindow tries fn against Current then Next (not Prev). -func (w EpochTrio) VerifyInWindow(fn func(*Committee) error) (*Epoch, error) { - for _, ep := range [2]*Epoch{w.Current, w.Next} { - if fn(ep.Committee()) == nil { - return ep, nil - } - } - return nil, fmt.Errorf("not accepted by current or next epoch in %v", w) -} - // String returns a compact description of the epoch indices in the window. -func (w EpochTrio) String() string { +func (w EpochDuo) String() string { s := "epochs [" sep := "" for _, oep := range w.all() { diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 0451ac6bf0..4afe792502 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -1,132 +1,64 @@ package types_test import ( - "errors" "testing" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// makeThreeEpochs returns three consecutive epochs with non-overlapping road ranges. -func makeThreeEpochs(t *testing.T) (prev, current, next *types.Epoch, keys []types.SecretKey) { +func testDuoEpochs(t *testing.T) (prev, current *types.Epoch) { t.Helper() rng := utils.TestRng() - sks := utils.GenSliceN(rng, 3, types.GenSecretKey) weights := map[types.PublicKey]uint64{} - for _, sk := range sks { - weights[sk.Public()] = 1 + for range 3 { + weights[types.GenSecretKey(rng).Public()] = 1 } committee := utils.OrPanic1(types.NewCommittee(weights)) prev = types.NewEpoch(0, types.RoadRange{First: 0, Next: 100}, utils.GenTimestamp(rng), committee, 1) current = types.NewEpoch(1, types.RoadRange{First: 100, Next: 200}, utils.GenTimestamp(rng), committee, 101) - next = types.NewEpoch(2, types.RoadRange{First: 200, Next: 300}, utils.GenTimestamp(rng), committee, 201) - return prev, current, next, sks + return prev, current } -// --- EpochForRoad --- - func TestEpochForRoad_HitsCurrentEpoch(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} + _, current := testDuoEpochs(t) + w := types.EpochDuo{Current: current} ep, err := w.EpochForRoad(150) if err != nil { t.Fatalf("EpochForRoad(150): %v", err) } - if ep.EpochIndex() != current.EpochIndex() { - t.Fatalf("got epoch %d, want current (%d)", ep.EpochIndex(), current.EpochIndex()) + if ep != current { + t.Fatalf("got %v, want current", ep) } } func TestEpochForRoad_HitsPrevEpoch(t *testing.T) { - prev, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Prev: utils.Some(prev), Current: current, Next: next} + prev, current := testDuoEpochs(t) + w := types.EpochDuo{Prev: utils.Some(prev), Current: current} ep, err := w.EpochForRoad(50) if err != nil { t.Fatalf("EpochForRoad(50): %v", err) } - if ep.EpochIndex() != prev.EpochIndex() { - t.Fatalf("got epoch %d, want prev (%d)", ep.EpochIndex(), prev.EpochIndex()) - } -} - -func TestEpochForRoad_HitsNextEpoch(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} - ep, err := w.EpochForRoad(250) - if err != nil { - t.Fatalf("EpochForRoad(250): %v", err) - } - if ep.EpochIndex() != next.EpochIndex() { - t.Fatalf("got epoch %d, want next (%d)", ep.EpochIndex(), next.EpochIndex()) + if ep != prev { + t.Fatalf("got %v, want prev", ep) } } func TestEpochForRoad_OutsideWindowReturnsError(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} + _, current := testDuoEpochs(t) + w := types.EpochDuo{Current: current} if _, err := w.EpochForRoad(999); err == nil { t.Fatal("EpochForRoad(999) expected error, got nil") } } -func TestEpochForCommitRoad_CurrentAndNext(t *testing.T) { - prev, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Prev: utils.Some(prev), Current: current, Next: next} - - ep, err := w.EpochForCommitRoad(150) - if err != nil { - t.Fatalf("Current: %v", err) - } - if ep.EpochIndex() != current.EpochIndex() { - t.Fatalf("got epoch %d, want current", ep.EpochIndex()) - } - ep, err = w.EpochForCommitRoad(250) - if err != nil { - t.Fatalf("Next: %v", err) - } - if ep.EpochIndex() != next.EpochIndex() { - t.Fatalf("got epoch %d, want next", ep.EpochIndex()) - } -} - -func TestEpochForCommitRoad_BeforeCurrentReturnsError(t *testing.T) { - prev, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Prev: utils.Some(prev), Current: current, Next: next} - if _, err := w.EpochForCommitRoad(50); err == nil { - t.Fatal("Prev road expected error (stale drop), got nil") - } - if _, err := w.EpochForCommitRoad(0); err == nil { - t.Fatal("road before Prev expected error, got nil") - } -} - -func TestEpochForCommitRoad_AboveNextPanics(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} - defer func() { - if recover() == nil { - t.Fatal("expected panic for road above Next") - } - }() - _, _ = w.EpochForCommitRoad(999) -} - func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { - // Regression: genesis epoch used to have OpenRoadRange (Next=MaxUint64). - // EpochForRoad iterated [Prev, Current, Next], so Prev.Has(any) was always - // true — every lookup returned epoch 0 instead of the correct epoch. rng := utils.TestRng() - sks := utils.GenSliceN(rng, 3, types.GenSecretKey) - weights := map[types.PublicKey]uint64{} - for _, sk := range sks { - weights[sk.Public()] = 1 - } + weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} committee := utils.OrPanic1(types.NewCommittee(weights)) openEpoch := types.NewEpoch(0, types.OpenRoadRange(), utils.GenTimestamp(rng), committee, 1) current := types.NewEpoch(1, types.RoadRange{First: 100, Next: 200}, utils.GenTimestamp(rng), committee, 101) - next := types.NewEpoch(2, types.RoadRange{First: 200, Next: 300}, utils.GenTimestamp(rng), committee, 201) - w := types.EpochTrio{Prev: utils.Some(openEpoch), Current: current, Next: next} + w := types.EpochDuo{Prev: utils.Some(openEpoch), Current: current} ep, err := w.EpochForRoad(150) if err != nil { t.Fatalf("EpochForRoad(150): %v", err) @@ -138,83 +70,9 @@ func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { } func TestEpochForRoad_AbsentPrevSkipped(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} - // Road 50 belongs to prev, which is absent — should return error, not panic. + _, current := testDuoEpochs(t) + w := types.EpochDuo{Current: current} if _, err := w.EpochForRoad(50); err == nil { t.Fatal("EpochForRoad(50) with absent Prev expected error, got nil") } } - -// --- CurrentAndNextLanes --- - -func TestCurrentAndNextLanes_UnionOfBoth(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} - lanes := w.CurrentAndNextLanes() - for lane := range current.Committee().Lanes().All() { - if _, ok := lanes[lane]; !ok { - t.Fatalf("lane %v from Current missing from result", lane) - } - } - for lane := range next.Committee().Lanes().All() { - if _, ok := lanes[lane]; !ok { - t.Fatalf("lane %v from Next missing from result", lane) - } - } -} - -// --- VerifyInWindow --- - -func TestVerifyInWindow_MatchesCurrent(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} - ep, err := w.VerifyInWindow(func(c *types.Committee) error { return nil }) - if err != nil { - t.Fatalf("VerifyInWindow: %v", err) - } - if ep.EpochIndex() != current.EpochIndex() { - t.Fatalf("got epoch %d, want current (%d)", ep.EpochIndex(), current.EpochIndex()) - } -} - -func TestVerifyInWindow_FallsBackToNext(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} - first := true - ep, err := w.VerifyInWindow(func(*types.Committee) error { - if first { - first = false - return errors.New("reject") - } - return nil - }) - if err != nil { - t.Fatalf("VerifyInWindow: %v", err) - } - if ep.EpochIndex() != next.EpochIndex() { - t.Fatalf("got epoch %d, want next (%d)", ep.EpochIndex(), next.EpochIndex()) - } -} - -func TestVerifyInWindow_NoneMatchReturnsError(t *testing.T) { - _, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Current: current, Next: next} - if _, err := w.VerifyInWindow(func(*types.Committee) error { return errors.New("reject") }); err == nil { - t.Fatal("VerifyInWindow expected error when fn rejects all, got nil") - } -} - -func TestVerifyInWindow_SkipsPrev(t *testing.T) { - prev, current, next, _ := makeThreeEpochs(t) - w := types.EpochTrio{Prev: utils.Some(prev), Current: current, Next: next} - callCount := 0 - _, _ = w.VerifyInWindow(func(*types.Committee) error { - callCount++ - return errors.New("reject") - }) - // fn should be called for Current and Next only — not Prev. - if callCount != 2 { - t.Fatalf("VerifyInWindow called fn %d times, want 2 (Current + Next only)", callCount) - } -} diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index c40d0e8f76..4ee73b7ee5 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -14,7 +14,7 @@ import ( type inner struct { latestAppQC utils.Option[*types.AppQC] latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] - epochTrio utils.AtomicSend[types.EpochTrio] // Store under Lock; State holds Recv + epochDuo utils.AtomicSend[types.EpochDuo] // Store under Lock; State holds Recv appVotes *queue[types.GlobalBlockNumber, appVotes] commitQCs *queue[types.RoadIndex, *types.CommitQC] lanes map[types.LaneID]*laneState @@ -75,26 +75,26 @@ func (ls *loadedAvailState) nextCommitQC() types.RoadIndex { return tip } -func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailState]) (*inner, error) { +func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailState]) (*inner, error) { lanes := map[types.LaneID]*laneState{} - for lane := range startEpochTrio.CurrentAndNextLanes() { + for lane := range startEpochDuo.Current.Committee().Lanes().All() { lanes[lane] = newLaneState() } i := &inner{ latestAppQC: utils.None[*types.AppQC](), latestCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), - epochTrio: utils.NewAtomicSend(startEpochTrio), + epochDuo: utils.NewAtomicSend(startEpochDuo), appVotes: newQueue[types.GlobalBlockNumber, appVotes](), commitQCs: newQueue[types.RoadIndex, *types.CommitQC](), lanes: lanes, } l, ok := loaded.Get() if !ok { - if startEpochTrio.Current.EpochIndex() > 0 { - return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochTrio.Current.EpochIndex()) + if startEpochDuo.Current.EpochIndex() > 0 { + return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } - i.appVotes.prune(startEpochTrio.Current.FirstBlock()) + i.appVotes.prune(startEpochDuo.Current.FirstBlock()) return i, nil } @@ -113,12 +113,12 @@ func newInner(startEpochTrio types.EpochTrio, loaded utils.Option[*loadedAvailSt for lane, ls := range i.lanes { ls.persistedBlockStart = anchor.CommitQC.LaneRange(lane).First() } - } else if startEpochTrio.Current.EpochIndex() == 0 { + } else if startEpochDuo.Current.EpochIndex() == 0 { // No anchor: don't raise appVotes to a tip epoch's FirstBlock — live // advanceEpoch also leaves appVotes at the genesis floor. - i.appVotes.prune(startEpochTrio.Current.FirstBlock()) + i.appVotes.prune(startEpochDuo.Current.FirstBlock()) } else { - return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochTrio.Current.EpochIndex()) + return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } // Restore persisted CommitQCs. prune() may have already pushed the @@ -173,20 +173,21 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ return i.lanes[lane].votes.q[n].laneQC() } -// advanceEpoch applies nextTrio at an epoch boundary. +// advanceEpoch applies nextDuo at an epoch boundary. // appVotes are pruned on CommitQC anchor arrival (prune), not here. // // TODO(lane-expiry): do not delete old lanes here until epoch-scoped lane IDs exist. -func (i *inner) advanceEpoch(nextTrio types.EpochTrio) { - for lane := range nextTrio.CurrentAndNextLanes() { +func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { + current := nextDuo.Current + for lane := range current.Committee().Lanes().All() { i.getOrInsertLane(lane) } for _, ls := range i.lanes { for n := ls.votes.first; n < ls.votes.next; n++ { - ls.votes.q[n].reweight(nextTrio.Current) + ls.votes.q[n].reweight(current) } } - i.epochTrio.Store(nextTrio) + i.epochDuo.Store(nextDuo) } // prune advances the state to account for a new AppQC/CommitQC pair. diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 215790b5f8..7076a34257 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -68,7 +68,7 @@ func TestNewInnerFreshStart(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.None[*loadedAvailState]()) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.None[*loadedAvailState]()) require.NoError(t, err) require.False(t, i.latestAppQC.IsPresent()) @@ -105,7 +105,7 @@ func TestNewInnerLoadedNoAnchor(t *testing.T) { loaded := &loadedAvailState{} - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // No anchor loaded, app votes should start at the registry's first block. @@ -118,12 +118,12 @@ func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) registry.AdvanceIfNeeded(0) - trio := utils.OrPanic1(registry.TrioAt(epoch.EpochLength)) - require.Equal(t, types.EpochIndex(1), trio.Current.EpochIndex()) + duo := utils.OrPanic1(registry.DuoAt(epoch.EpochLength)) + require.Equal(t, types.EpochIndex(1), duo.Current.EpochIndex()) - _, err := newInner(trio, utils.Some(&loadedAvailState{})) + _, err := newInner(duo, utils.Some(&loadedAvailState{})) require.Error(t, err) - _, err = newInner(trio, utils.None[*loadedAvailState]()) + _, err = newInner(duo, utils.None[*loadedAvailState]()) require.Error(t, err) } @@ -145,7 +145,7 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) q := i.lanes[lane].blocks @@ -174,7 +174,7 @@ func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: {}}, } - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) q := i.lanes[lane].blocks @@ -194,7 +194,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{unknownLane: {{Number: 0, Proposal: b}}}, } - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) for lane := range registry.LatestEpoch().Committee().Lanes().All() { @@ -231,7 +231,7 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs0, lane1: bs1}, } - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) q0 := i.lanes[lane0].blocks @@ -268,7 +268,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // Without anchor, commitQCs.first = 0. All 3 should be restored. @@ -314,7 +314,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // latestAppQC should be set by prune. @@ -375,7 +375,7 @@ func TestNewInnerLoadedAllThree(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // AppQC restored. @@ -404,7 +404,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 4) lane := keys[0].Public() - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.None[*loadedAvailState]()) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.None[*loadedAvailState]()) require.NoError(t, err) // Push blocks 0-4 on one lane. @@ -479,7 +479,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[5]}), } - inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune() pushes the anchor's CommitQC into the queue. @@ -508,7 +508,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), } - inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune() should push the anchor's CommitQC into the queue. @@ -551,7 +551,7 @@ func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } @@ -564,7 +564,7 @@ func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { commitQCs: nil, } - inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) @@ -599,7 +599,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // Only QC@10 loaded. @@ -648,7 +648,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune(3) pushes QC@3 (next=4). Indices 1,2,3 are skipped. 4,5 pushed. @@ -687,7 +687,7 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } @@ -711,7 +711,7 @@ func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } @@ -739,7 +739,7 @@ func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "parent hash mismatch") } @@ -765,7 +765,7 @@ func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "exceeds capacity") } @@ -808,7 +808,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune() should advance block queue first to the prune anchor's lane range. @@ -844,7 +844,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { }, } - i, err := newInner(utils.OrPanic1(registry.TrioAt(0)), utils.Some(loaded)) + i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune(appQC@1, pruneQC@1) should advance commitQCs.first to 1. @@ -868,13 +868,12 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { prev = utils.Some(qcs[i]) } wantAppFirst := qcs[1].GlobalRange().First - // Synthetic tip trio with FirstBlock above the prune floor (placeholder + // Synthetic tip duo with FirstBlock above the prune floor (placeholder // registry epochs share genesis FirstBlock, so inflate for this invariant). tipFirst := wantAppFirst + 1000 c := ep0.Committee() tipCurrent := types.NewEpoch(1, types.RoadRange{First: epoch.EpochLength, Next: 2 * epoch.EpochLength}, ep0.FirstTimestamp(), c, tipFirst) - tipNext := types.NewEpoch(2, types.RoadRange{First: 2 * epoch.EpochLength, Next: 3 * epoch.EpochLength}, ep0.FirstTimestamp(), c, tipFirst) - tipTrio := types.EpochTrio{Prev: utils.Some(ep0), Current: tipCurrent, Next: tipNext} + tipDuo := types.EpochDuo{Prev: utils.Some(ep0), Current: tipCurrent} ap := types.NewAppProposal(wantAppFirst, qcs[1].Index(), types.GenAppHash(rng), 0) loaded := &loadedAvailState{ @@ -888,7 +887,7 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { }, } - inner, err := newInner(tipTrio, utils.Some(loaded)) + inner, err := newInner(tipDuo, utils.Some(loaded)) require.NoError(t, err) require.Equal(t, wantAppFirst, inner.appVotes.first, "appVotes must follow prune-anchor GlobalRange, not tip Current.FirstBlock") @@ -898,20 +897,20 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) - trio := utils.OrPanic1(registry.TrioAt(0)) + duo := utils.OrPanic1(registry.DuoAt(0)) - i, err := newInner(trio, utils.None[*loadedAvailState]()) + i, err := newInner(duo, utils.None[*loadedAvailState]()) require.NoError(t, err) // All current lanes are present after construction. - for lane := range trio.Current.Committee().Lanes().All() { + for lane := range duo.Current.Committee().Lanes().All() { require.Contains(t, i.lanes, lane, "lane %v missing after newInner", lane) } - // Add a lane not in the trio — until lane-expiry lands, advance must not + // Add a lane not in the duo — until lane-expiry lands, advance must not // delete it (ever-growing map). var realLane types.LaneID - for l := range trio.Current.Committee().Lanes().All() { + for l := range duo.Current.Committee().Lanes().All() { realLane = l break } @@ -919,7 +918,7 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { bogusLane := bogusSK.Public() i.lanes[bogusLane] = newLaneState() - i.advanceEpoch(trio) + i.advanceEpoch(duo) require.Contains(t, i.lanes, bogusLane, "old lanes must be retained until lane-expiry") require.Contains(t, i.lanes, realLane, "active lane removed incorrectly") } @@ -927,15 +926,15 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { func TestAdvanceEpoch_EmptyQueuesNoop(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) - trio := utils.OrPanic1(registry.TrioAt(0)) + duo := utils.OrPanic1(registry.DuoAt(0)) - i, err := newInner(trio, utils.None[*loadedAvailState]()) + i, err := newInner(duo, utils.None[*loadedAvailState]()) require.NoError(t, err) - // No votes in any queue; advancing to the same trio is a safe no-op that + // No votes in any queue; advancing to the same duo is a safe no-op that // keeps the current lane set intact. - i.advanceEpoch(trio) - for lane := range trio.Current.Committee().Lanes().All() { + i.advanceEpoch(duo) + for lane := range duo.Current.Committee().Lanes().All() { require.Contains(t, i.lanes, lane) } } @@ -944,22 +943,22 @@ func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) - // trio0: Prev=nil, Current=epoch0, Next=epoch1 - trio0 := utils.OrPanic1(registry.TrioAt(0)) - i, err := newInner(trio0, utils.None[*loadedAvailState]()) + // duo0: Prev=nil, Current=epoch0 + duo0 := utils.OrPanic1(registry.DuoAt(0)) + i, err := newInner(duo0, utils.None[*loadedAvailState]()) require.NoError(t, err) // Collect a lane from epoch0 (will become Prev after advance). var epoch0Lane types.LaneID - for l := range trio0.Current.Committee().Lanes().All() { + for l := range duo0.Current.Committee().Lanes().All() { epoch0Lane = l break } require.Contains(t, i.lanes, epoch0Lane, "epoch0 lane missing before reweight") - // trio1: Prev=epoch0, Current=epoch1, Next=epoch2 - trio1 := utils.OrPanic1(registry.TrioAt(epoch.EpochLength)) - i.advanceEpoch(trio1) + // duo1: Prev=epoch0, Current=epoch1 + duo1 := utils.OrPanic1(registry.DuoAt(epoch.EpochLength)) + i.advanceEpoch(duo1) // Epoch0 lane is now in Prev — must be retained for boundary QC collection. require.Contains(t, i.lanes, epoch0Lane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 26a06b3920..278f9fe290 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -31,10 +31,10 @@ const BlocksPerLane = 3 * types.MaxLaneRangeInProposal // to trigger internal pruning, which allows it to manage memory independently // of the main consensus loop. type State struct { - key types.SecretKey - data *data.State - inner utils.Watch[*inner] - epochTrio utils.AtomicRecv[types.EpochTrio] // Load-only view of inner.epochTrio + key types.SecretKey + data *data.State + inner utils.Watch[*inner] + epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epochDuo // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. @@ -157,27 +157,27 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } - // Operating trio is the CommitQC tipcut (not the prune-anchor road). - // Tip may lead data.SetupInitialTrio; seed around it before TrioAt. + // Operating duo is the CommitQC tipcut (not the prune-anchor road). + // Tip may lead data.SetupInitialDuo; seed around it before DuoAt. commitTip := types.RoadIndex(0) if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() } - data.Registry().SetupInitialTrio(commitTip) - startTrio, err := data.Registry().TrioAt(commitTip) + data.Registry().SetupInitialDuo(commitTip) + startDuo, err := data.Registry().DuoAt(commitTip) if err != nil { - return nil, fmt.Errorf("TrioAt(%d): %w", commitTip, err) + return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) } - inner, err := newInner(startTrio, loaded) + inner, err := newInner(startDuo, loaded) if err != nil { return nil, err } // Truncate WAL entries below the prune anchor that were filtered out by - // loadPersistedState. Lanes come from the operating (tip) trio. + // loadPersistedState. Lanes come from the operating (tip) duo. if ls, ok := loaded.Get(); ok { if anchor, ok := ls.pruneAnchor.Get(); ok { - for lane := range startTrio.CurrentAndNextLanes() { + for lane := range startDuo.Current.Committee().Lanes().All() { if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { return nil, fmt.Errorf("prune stale block WAL entries: %w", err) } @@ -192,7 +192,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin key: key, data: data, inner: utils.NewWatch(inner), - epochTrio: inner.epochTrio.Subscribe(), + epochDuo: inner.epochDuo.Subscribe(), persisters: pers, }, nil } @@ -224,16 +224,16 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error return err } -// waitEpochForRoad blocks until roadIdx is in Prev|Current|Next, backpressuring +// waitEpochForRoad blocks until roadIdx is in Prev|Current, backpressuring // callers (e.g. peer streams) until this node catches up. // Returns None if the road has fallen behind the window (stale). func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { - trio, err := s.epochTrio.Wait(ctx, func(trio types.EpochTrio) bool { - if _, err := trio.EpochForRoad(roadIdx); err == nil { + duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { + if _, err := duo.EpochForRoad(roadIdx); err == nil { return true } - first := trio.Current.RoadRange().First - if prev, ok := trio.Prev.Get(); ok { + first := duo.Current.RoadRange().First + if prev, ok := duo.Prev.Get(); ok { first = prev.RoadRange().First } return roadIdx < first @@ -241,7 +241,7 @@ func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) ( if err != nil { return utils.None[*types.Epoch](), err } - if ep, err := trio.EpochForRoad(roadIdx); err == nil { + if ep, err := duo.EpochForRoad(roadIdx); err == nil { return utils.Some(ep), nil } return utils.None[*types.Epoch](), nil @@ -331,35 +331,36 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - trio := s.epochTrio.Load() - ep, err := trio.EpochForCommitRoad(idx) - if err != nil { - logger.Info("dropping stale CommitQC: road before Current", - slog.Uint64("road", uint64(idx)), "err", err) + duo := s.epochDuo.Load() + // CommitQCs are admitted for Current only; new-committee traffic starts + // after the boundary advances the duo. + ep := duo.Current + if !ep.RoadRange().Has(idx) { + logger.Info("dropping CommitQC: road outside Current", + slog.Uint64("road", uint64(idx)), "duo", duo.String()) return nil } if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } - // Boundary: switch to the next epoch on Current's last CommitQC (not the - // first of Next), so we can immediately collect votes for new-epoch lanes. - // Resolve next trio off-lock (WaitForTrio). - var nextTrio *types.EpochTrio - if idx+1 == trio.Current.RoadRange().Next { - nt, err := s.data.Registry().WaitForTrio(ctx, idx+1) + // Boundary: switch to the next epoch on Current's last CommitQC. + // Resolve next duo off-lock (WaitForDuo). + var nextDuo *types.EpochDuo + if idx+1 == duo.Current.RoadRange().Next { + nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) if err != nil { return err } - nextTrio = &nt + nextDuo = &nt } for inner, ctrl := range s.inner.Lock() { if idx != inner.commitQCs.next { return nil } - if nextTrio != nil { - inner.advanceEpoch(*nextTrio) + if nextDuo != nil { + inner.advanceEpoch(*nextDuo) } inner.commitQCs.pushBack(qc) metrics.ObserveCommitQC(qc) @@ -377,7 +378,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if err := s.waitForCommitQC(ctx, idx); err != nil { return err } - ep, err := s.epochTrio.Load().EpochForRoad(idx) + ep, err := s.epochDuo.Load().EpochForRoad(idx) if err != nil { logger.Info("dropping stale AppVote: road outside epoch window", slog.Uint64("road", uint64(idx)), "err", err) @@ -505,12 +506,11 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos if p.Key() != h.Lane() { return fmt.Errorf("signer %v does not match lane %v", p.Key(), h.Lane()) } - if _, err := s.epochTrio.Load().VerifyInWindow(func(c *types.Committee) error { - if err := p.Msg().Verify(c); err != nil { - return err - } - return p.VerifySig(c) - }); err != nil { + c := s.epochDuo.Load().Current.Committee() + if err := p.Msg().Verify(c); err != nil { + return fmt.Errorf("block.Verify(): %w", err) + } + if err := p.VerifySig(c); err != nil { return fmt.Errorf("block.Verify(): %w", err) } for inner, ctrl := range s.inner.Lock() { @@ -558,12 +558,11 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos // Waits until the lane has enough capacity for the new vote. // It does NOT wait for the previous votes. func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { - if _, err := s.epochTrio.Load().VerifyInWindow(func(c *types.Committee) error { - if err := vote.Msg().Verify(c); err != nil { - return err - } - return vote.VerifySig(c) - }); err != nil { + c := s.epochDuo.Load().Current.Committee() + if err := vote.Msg().Verify(c); err != nil { + return fmt.Errorf("vote.Verify(): %w", err) + } + if err := vote.VerifySig(c); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } h := vote.Msg().Header() @@ -584,8 +583,8 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - trio := s.epochTrio.Load() - if q.q[h.BlockNumber()].pushVote(trio.Current, vote).IsPresent() { + duo := s.epochDuo.Load() + if q.q[h.BlockNumber()].pushVote(duo.Current, vote).IsPresent() { ctrl.Updated() } } @@ -633,7 +632,7 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful } // Collect the headers from the votes. var commitHeaders []*types.BlockHeader - ep, err := s.epochTrio.Load().EpochForRoad(qc.Proposal().Index()) + ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { // Window raced past this road ⇒ treat as pruned for the PushQC loop. return nil, nil, fmt.Errorf("epoch window advanced past road %d: %w", qc.Proposal().Index(), data.ErrPruned) @@ -670,7 +669,7 @@ func (s *State) WaitForLaneQCs( for inner, ctrl := range s.inner.Lock() { laneQCs := map[types.LaneID]*types.LaneQC{} for { - ep := inner.epochTrio.Load().Current + ep := inner.epochDuo.Load().Current for lane := range ep.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { @@ -834,7 +833,7 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { batchLanes[lane] = struct{}{} } if anchor, ok := anchorQC.Get(); ok { - ep, err := s.epochTrio.Load().EpochForRoad(anchor.Proposal().Index()) + ep, err := s.epochDuo.Load().EpochForRoad(anchor.Proposal().Index()) if err != nil { return fmt.Errorf("EpochForRoad(%d): %w", anchor.Proposal().Index(), err) } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 45d924a7ac..2506f753f6 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -878,13 +878,13 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) - // Center the operating window on epoch 2 so road 0 is outside Prev|Current|Next. + // Center the operating window on epoch 2 so road 0 is outside Prev|Current. for _, road := range []types.RoadIndex{0, epoch.EpochLength, 2 * epoch.EpochLength} { registry.AdvanceIfNeeded(road) } - farTrio := utils.OrPanic1(registry.TrioAt(2 * epoch.EpochLength)) + farDuo := utils.OrPanic1(registry.DuoAt(2 * epoch.EpochLength)) for inner := range state.inner.Lock() { - inner.epochTrio.Store(farTrio) + inner.epochDuo.Store(farDuo) } require.NoError(t, state.PushAppQC(t.Context(), appQC, qc0)) @@ -894,7 +894,8 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) - registry.AdvanceIfNeeded(0) + registry.AdvanceIfNeeded(0) // seeds epoch 1 + registry.AdvanceIfNeeded(epoch.EpochLength) // seeds epoch 2 future := utils.OrPanic1(registry.EpochAt(2 * epoch.EpochLength)) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, @@ -936,9 +937,9 @@ func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - farTrio := utils.OrPanic1(registry.TrioAt(2 * epoch.EpochLength)) + farDuo := utils.OrPanic1(registry.DuoAt(2 * epoch.EpochLength)) for inner := range state.inner.Lock() { - inner.epochTrio.Store(farTrio) + inner.epochDuo.Store(farDuo) } ep, err := state.waitEpochForRoad(t.Context(), 0) @@ -951,7 +952,7 @@ func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { // the path where a late AppQC arrives after an epoch boundary has been crossed. func TestPushAppQCPreviousEpoch(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 3) + registry, keys := epoch.GenRegistryAt(rng, 3, 1) epochN1, err := registry.EpochAt(0) // epoch 0 (N-1) require.NoError(t, err) @@ -985,28 +986,26 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { "AppQC from epoch N-1 should be accepted when registry has epoch N-1 registered") } -// TestRestartTrioFromCommitTipNeedsNPlus2 covers NewState seeding: TrioAt(tip) -// for tip in epoch N+1 needs N+2, but SetupInitialTrio(0) only seeded {0,1}. -func TestRestartTrioFromCommitTipNeedsNPlus2(t *testing.T) { +// TestRestartDuoFromCommitTipNeedsSetup covers NewState seeding: DuoAt(tip) +// for tip past the seeded window needs SetupInitialDuo. +func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { rng := utils.TestRng() - registry, _, _ := epoch.GenRegistry(rng, 3) - // GenRegistry/NewRegistry → SetupInitialTrio(0) → epochs {0,1} only. - ep0, err := registry.TrioAt(0) + registry, _ := epoch.GenRegistryAt(rng, 3, 0) + // GenRegistryAt(0) / SetupInitialDuo(0) → epoch {0} only. + ep0, err := registry.DuoAt(0) require.NoError(t, err) - tip := ep0.Current.RoadRange().Next // first road of epoch 1 - require.Equal(t, types.RoadIndex(epoch.EpochLength), tip) - - _, err = registry.TrioAt(tip) - require.Error(t, err, "TrioAt(epoch-1 tip) must fail without epoch 2") - - // Same seeding NewState does before TrioAt(commit tip). - registry.SetupInitialTrio(tip) - tipTrio, err := registry.TrioAt(tip) + tip1 := ep0.Current.RoadRange().Next // first road of epoch 1 + require.Equal(t, types.RoadIndex(epoch.EpochLength), tip1) + _, err = registry.DuoAt(tip1) + require.Error(t, err, "DuoAt(epoch-1 tip) must fail without epoch 1") + + // Same seeding NewState does before DuoAt(commit tip). + registry.SetupInitialDuo(tip1) + tipDuo1, err := registry.DuoAt(tip1) require.NoError(t, err) - require.Equal(t, types.EpochIndex(1), tipTrio.Current.EpochIndex()) - require.Equal(t, types.EpochIndex(2), tipTrio.Next.EpochIndex()) + require.Equal(t, types.EpochIndex(1), tipDuo1.Current.EpochIndex()) - _, err = newInner(tipTrio, utils.None[*loadedAvailState]()) + _, err = newInner(tipDuo1, utils.None[*loadedAvailState]()) require.Error(t, err, "epoch > 0 requires a prune anchor") } diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 91cbe83963..9b8770c6e1 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -121,7 +121,7 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( // View epoch = tipcut road; CommitQC may still be the prior epoch's last road. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) // Seed around consensus tip (may lead data WAL tip). TODO: verified snapshot. - registry.SetupInitialTrio(nextViewRoad) + registry.SetupInitialDuo(nextViewRoad) viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) @@ -156,7 +156,7 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { return nil } // Invariant: N+1 is registered before this CommitQC (setup / AdvanceIfNeeded). - // Hard-error if missing — do not WaitForTrio like avail/data do for N+2. + // Hard-error if missing — do not WaitForDuo like avail/data do for N+1. nextEp, err := i.registry.EpochAt(qc.Proposal().Index() + 1) if err != nil { logger.Error("next epoch not in registry at CommitQC boundary", diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index fe28dcee4f..6a1edc334d 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -160,8 +160,8 @@ type inner struct { // RetainHeight pruning, making this in-memory index obsolete. blockHashes map[types.BlockHeaderHash]types.GlobalBlockNumber - // Store under Watch.Lock; readers use State.epochTrio. - epochTrio utils.AtomicSend[types.EpochTrio] + // Store under Watch.Lock; readers use State.epochDuo. + epochDuo utils.AtomicSend[types.EpochDuo] // first <= nextAppProposal <= nextBlockToPersist <= nextBlock <= nextQC // @@ -279,11 +279,11 @@ func (i *inner) pruneFirst(now time.Time, m *metrics.Metrics) { // State of the chain. // Contains blocks in global order and proofs of their finality. type State struct { - cfg *Config - metrics *metrics.Metrics - inner utils.Watch[*inner] - epochTrio utils.AtomicRecv[types.EpochTrio] // Load-only view of inner.epochTrio; EvmProxy reads via EpochTrio() - dataWAL *DataWAL + cfg *Config + metrics *metrics.Metrics + inner utils.Watch[*inner] + epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epochDuo; EvmProxy reads via EpochDuo() + dataWAL *DataWAL } // NewState constructs a new data State. @@ -301,7 +301,7 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if dataFirst > cfg.Registry.FirstBlock() { inner.skipTo(dataFirst) } - // Seed {N-2..N+1} around the WAL tip for QC verify / TrioAt. + // Seed {N-1, N} around the WAL tip for QC verify / DuoAt. // Invariant: CommitQC WAL span behind that tip is within this window // (pruned vs latest AppQC today). TODO: verified snapshot / state-sync. loadedQCs := dataWAL.CommitQCs.ConsumeLoaded() @@ -309,7 +309,7 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if n := len(loadedQCs); n > 0 { setupRoad = loadedQCs[n-1].QC().Proposal().Index() + 1 } - cfg.Registry.SetupInitialTrio(setupRoad) + cfg.Registry.SetupInitialDuo(setupRoad) // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. for _, qc := range loadedQCs { @@ -362,32 +362,32 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { initRoad = lastQC.QC().Proposal().Index() + 1 } } - initTrio, err := cfg.Registry.TrioAt(initRoad) + initDuo, err := cfg.Registry.DuoAt(initRoad) if err != nil { - return nil, fmt.Errorf("init epochTrio: %w", err) + return nil, fmt.Errorf("init epochDuo: %w", err) } - inner.epochTrio = utils.NewAtomicSend(initTrio) + inner.epochDuo = utils.NewAtomicSend(initDuo) return &State{ - cfg: cfg, - metrics: metrics.Get(), - inner: utils.NewWatch(inner), - epochTrio: inner.epochTrio.Subscribe(), - dataWAL: dataWAL, + cfg: cfg, + metrics: metrics.Get(), + inner: utils.NewWatch(inner), + epochDuo: inner.epochDuo.Subscribe(), + dataWAL: dataWAL, }, nil } // Registry returns the epoch registry. func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } -// EpochTrio is a point-in-time snapshot; re-call after a boundary advance. -func (s *State) EpochTrio() types.EpochTrio { return s.epochTrio.Load() } +// EpochDuo is a point-in-time snapshot; re-call after a boundary advance. +func (s *State) EpochDuo() types.EpochDuo { return s.epochDuo.Load() } // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { // Out-of-window ⇒ below prune tip; reject. - ep, err := s.epochTrio.Load().EpochForRoad(qc.QC().Proposal().Index()) + ep, err := s.epochDuo.Load().EpochForRoad(qc.QC().Proposal().Index()) if err != nil { return err } @@ -421,30 +421,30 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("b.Verify(): %w", err) } } - // Boundary: resolve next trio off-lock before mutating nextQC - // (WaitForTrio), so a failed wait cannot strand the tip. + // Boundary: resolve next duo off-lock before mutating nextQC + // (WaitForDuo), so a failed wait cannot strand the tip. idx := qc.QC().Proposal().Index() - var nextTrio *types.EpochTrio - trio := s.epochTrio.Load() - if needQC && idx+1 == trio.Current.RoadRange().Next { - nt, err := s.cfg.Registry.WaitForTrio(ctx, idx+1) + var nextDuo *types.EpochDuo + duo := s.epochDuo.Load() + if needQC && idx+1 == duo.Current.RoadRange().Next { + nt, err := s.cfg.Registry.WaitForDuo(ctx, idx+1) if err != nil { return err } - nextTrio = &nt + nextDuo = &nt } // Atomically insert QC and blocks. for inner, ctrl := range s.inner.Lock() { if needQC { - // Re-check under lock: only the inserter may store nextTrio + // Re-check under lock: only the inserter may store nextDuo // (stale concurrent PushQC must not regress the window). applied := inner.nextQC == gr.First for inner.nextQC < gr.Next { inner.qcs[inner.nextQC] = qc inner.nextQC += 1 } - if applied && nextTrio != nil { - inner.epochTrio.Store(*nextTrio) + if applied && nextDuo != nil { + inner.epochDuo.Store(*nextDuo) } ctrl.Updated() } @@ -496,7 +496,7 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block return nil } var err error - ep, err = s.epochTrio.Load().EpochForRoad(inner.qcs[n].QC().Proposal().Index()) + ep, err = s.epochDuo.Load().EpochForRoad(inner.qcs[n].QC().Proposal().Index()) if err != nil { return fmt.Errorf("epoch not in window: %w", err) } diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 718ed5cc8c..b6faeaf6dd 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -1137,15 +1137,15 @@ func TestPushBlockWaitsForQC(t *testing.T) { }) } -func TestPushQC_AdvancesEpochTrioAtBoundary(t *testing.T) { +func TestPushQC_AdvancesEpochDuoAtBoundary(t *testing.T) { synctest.Test(t, func(t *testing.T) { rng := utils.TestRng() - // startEpoch=1: seeds epochs 0, 1, 2 so TrioAt works after advancing past epoch 0. + // startEpoch=1: seeds epochs 0, 1, 2 so DuoAt works after advancing past epoch 0. registry, keys := epoch.GenRegistryAt(rng, 3, 1) state := utils.OrPanic1(NewState(&Config{Registry: registry}, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) - ep0 := state.epochTrio.Load().Current + ep0 := state.epochDuo.Load().Current require.Equal(t, types.EpochIndex(0), ep0.EpochIndex()) // Build a CommitQC at the last road of epoch 0 (no lane blocks needed). @@ -1159,8 +1159,8 @@ func TestPushQC_AdvancesEpochTrioAtBoundary(t *testing.T) { require.NoError(t, state.PushQC(t.Context(), qc, nil)) - if got := state.epochTrio.Load().Current.EpochIndex(); got != 1 { - t.Fatalf("epochTrio.Current after epoch boundary = %d, want 1", got) + if got := state.epochDuo.Load().Current.EpochIndex(); got != 1 { + t.Fatalf("epochDuo.Current after epoch boundary = %d, want 1", got) } }) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 87f01b5d7c..d28a445834 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -21,13 +21,14 @@ type registryState struct { // All layers (consensus, data, avail) read from it. type Registry struct { state utils.RWMutex[*registryState] - // highestEpoch is a monotonic high-water mark for WaitForTrio. + // highestEpoch is a monotonic high-water mark for WaitForDuo. // Kept off registryState so EpochAt can stay on the RLock fast path. highestEpoch utils.AtomicSend[types.EpochIndex] } -// NewRegistry creates a Registry with the genesis committee and seeds epoch 1 -// so TrioAt(0) succeeds on a fresh node. +// NewRegistry creates a Registry with the genesis committee. +// SetupInitialDuo(0) seeds epoch 0; epoch 1 is registered by AdvanceIfNeeded +// (or WaitForDuo at the first boundary). func NewRegistry( committee *types.Committee, firstBlock types.GlobalBlockNumber, @@ -41,25 +42,24 @@ func NewRegistry( }), highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), } - // Fresh start needs Current+Next for TrioAt(0). // TODO: in the future this information will be read from disk and verified - // (snapshots / state sync); until then seed a genesis placeholder trio. - r.SetupInitialTrio(0) + // (snapshots / state sync); until then seed a genesis placeholder. + r.SetupInitialDuo(0) return r, nil } -// SetupInitialTrio registers placeholder epochs {N-2..N+1} around roadIndex +// SetupInitialDuo registers placeholder epochs {N-1, N} around roadIndex // (clamped at 0) with the genesis committee. Idempotent for existing entries. +// The next epoch is seeded later by AdvanceIfNeeded / WaitForDuo at the boundary. // TODO: replace with verified snapshot / state-sync epoch info. -func (r *Registry) SetupInitialTrio(roadIndex types.RoadIndex) { +func (r *Registry) SetupInitialDuo(roadIndex types.RoadIndex) { n := types.EpochIndex(roadIndex / EpochLength) first := types.EpochIndex(0) - if n >= 2 { - first = n - 2 + if n >= 1 { + first = n - 1 } - last := n + 1 for s := range r.state.Lock() { - for idx := first; idx <= last; idx++ { + for idx := first; idx <= n; idx++ { if _, ok := s.m[idx]; ok { continue } @@ -78,7 +78,7 @@ func (r *Registry) FirstBlock() types.GlobalBlockNumber { } // EpochAt returns the epoch for the given road index. -// Returns an error if the epoch has not been registered via SetupInitialTrio or +// Returns an error if the epoch has not been registered via SetupInitialDuo or // AdvanceIfNeeded. func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { epochIdx := types.EpochIndex(roadIndex / EpochLength) @@ -103,7 +103,7 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type firstRoad := types.RoadIndex(uint64(epochIdx) * uint64(EpochLength)) epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: firstRoad + EpochLength}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) s.m[epochIdx] = epoch - // Wake WaitForTrio waiters. makeEpoch runs under the write lock, so this + // Wake WaitForDuo waiters. makeEpoch runs under the write lock, so this // Load/Store is serialized; highestEpoch only advances. if epochIdx > r.highestEpoch.Load() { r.highestEpoch.Store(epochIdx) @@ -111,63 +111,59 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type return epoch, nil } -// AdvanceIfNeeded seeds epoch N+2 when any road in epoch N is executed. -// Invariant: by the time Tipcut needs TrioAt(N.Next), N+2 is either already -// registered or waiters use WaitForTrio until this runs. -// TODO: pass the real N+2 committee once execution derives it. +// AdvanceIfNeeded seeds epoch N+1 when any road in epoch N is executed. +// Invariant: by the time Tipcut needs DuoAt(N.Next), N+1 is either already +// registered or waiters use WaitForDuo until this runs. +// TODO: pass the real N+1 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { - nextNextIdx := types.EpochIndex(roadIndex/EpochLength) + 2 + nextIdx := types.EpochIndex(roadIndex/EpochLength) + 1 // Fast path: epoch already seeded (common after the first block of the epoch). for s := range r.state.RLock() { - if _, ok := s.m[nextNextIdx]; ok { + if _, ok := s.m[nextIdx]; ok { return } } for s := range r.state.Lock() { - if _, ok := s.m[nextNextIdx]; !ok { - _, _ = r.makeEpoch(s, nextNextIdx) //nolint:errcheck // genesis always present + if _, ok := s.m[nextIdx]; !ok { + _, _ = r.makeEpoch(s, nextIdx) //nolint:errcheck // genesis always present } } } -// TrioAt returns the EpochTrio centered on the epoch containing roadIndex. -// Current and Next must already be present in the registry (callers seed them); -// returns an error if either is missing. Prev is absent only when Current is epoch 0. +// DuoAt returns the EpochDuo centered on the epoch containing roadIndex. +// Current must already be present; returns an error if missing. Prev is absent +// only when Current is epoch 0. // // The registry retains epochs indefinitely (no pruning). If pruning is added, // a missing epoch below the retain window should surface as ErrPruned so // callers can silently drop rather than Wait forever. -func (r *Registry) TrioAt(roadIndex types.RoadIndex) (types.EpochTrio, error) { +func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { centerIdx := types.EpochIndex(roadIndex / EpochLength) current, err := r.EpochAt(types.RoadIndex(centerIdx) * EpochLength) if err != nil { - return types.EpochTrio{}, fmt.Errorf("epoch %d (road %d) not in registry", centerIdx, roadIndex) + return types.EpochDuo{}, fmt.Errorf("epoch %d (road %d) not in registry", centerIdx, roadIndex) } - next, err := r.EpochAt(types.RoadIndex(centerIdx+1) * EpochLength) - if err != nil { - return types.EpochTrio{}, fmt.Errorf("next epoch %d not in registry", centerIdx+1) - } - trio := types.EpochTrio{Current: current, Next: next} + duo := types.EpochDuo{Current: current} if centerIdx > 0 { if prev, err := r.EpochAt(types.RoadIndex(centerIdx-1) * EpochLength); err == nil { - trio.Prev = utils.Some(prev) + duo.Prev = utils.Some(prev) } } - return trio, nil + return duo, nil } -// WaitForTrio blocks until TrioAt(roadIndex) can succeed (Next registered), -// then returns that trio. Same retention note as TrioAt. +// WaitForDuo blocks until DuoAt(roadIndex) can succeed (Current registered), +// then returns that duo. Same retention note as DuoAt. // Must not hold the avail/data inner lock (execution seeds via AdvanceIfNeeded). -func (r *Registry) WaitForTrio(ctx context.Context, roadIndex types.RoadIndex) (types.EpochTrio, error) { - if trio, err := r.TrioAt(roadIndex); err == nil { - return trio, nil +func (r *Registry) WaitForDuo(ctx context.Context, roadIndex types.RoadIndex) (types.EpochDuo, error) { + if duo, err := r.DuoAt(roadIndex); err == nil { + return duo, nil } centerIdx := types.EpochIndex(roadIndex / EpochLength) if _, err := r.highestEpoch.Subscribe().Wait(ctx, func(highest types.EpochIndex) bool { - return highest >= centerIdx+1 + return highest >= centerIdx }); err != nil { - return types.EpochTrio{}, err + return types.EpochDuo{}, err } - return r.TrioAt(roadIndex) + return r.DuoAt(roadIndex) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 5c6c7914d1..03fa4112aa 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -65,79 +65,69 @@ func TestEpochAt_ErrorIfNotRegistered(t *testing.T) { func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { r, _ := makeRegistry(t) - // Executing any block in epoch 0 seeds epoch 2 (N+2). - r.AdvanceIfNeeded(EpochLength - 1) - ep, err := r.EpochAt(2 * EpochLength) + // NewRegistry already seeds epoch 1. AdvanceIfNeeded is idempotent. + r.AdvanceIfNeeded(0) + ep, err := r.EpochAt(EpochLength) if err != nil { - t.Fatalf("EpochAt(2*EpochLength) after AdvanceIfNeeded: %v", err) + t.Fatalf("EpochAt(EpochLength) after AdvanceIfNeeded: %v", err) } - if ep.EpochIndex() != 2 { - t.Fatalf("EpochAt(2*EpochLength).EpochIndex() = %d, want 2", ep.EpochIndex()) + if ep.EpochIndex() != 1 { + t.Fatalf("EpochAt(EpochLength).EpochIndex() = %d, want 1", ep.EpochIndex()) } } -func TestSetupInitialTrio_WindowAroundTip(t *testing.T) { +func TestSetupInitialDuo_WindowAroundTip(t *testing.T) { r, _ := makeRegistry(t) - // N=5 → {3,4,5,6}. Epochs 0,1 already present from NewRegistry. - r.SetupInitialTrio(5 * EpochLength) - for _, idx := range []types.EpochIndex{3, 4, 5, 6} { + // N=5 → {4,5}. Epoch 0 already present from NewRegistry. + r.SetupInitialDuo(5 * EpochLength) + for _, idx := range []types.EpochIndex{4, 5} { ep, err := r.EpochAt(types.RoadIndex(idx) * EpochLength) if err != nil { - t.Fatalf("EpochAt(epoch %d) after SetupInitialTrio: %v", idx, err) + t.Fatalf("EpochAt(epoch %d) after SetupInitialDuo: %v", idx, err) } if ep.EpochIndex() != idx { t.Fatalf("EpochAt(epoch %d).EpochIndex() = %d, want %d", idx, ep.EpochIndex(), idx) } } - if _, err := r.EpochAt(2 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 2) should not be present after SetupInitialTrio(5*EpochLength)") + if _, err := r.EpochAt(3 * EpochLength); err == nil { + t.Fatal("EpochAt(epoch 3) should not be present after SetupInitialDuo(5*EpochLength)") } - if _, err := r.EpochAt(7 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present after SetupInitialTrio(5*EpochLength)") + if _, err := r.EpochAt(6 * EpochLength); err == nil { + t.Fatal("EpochAt(epoch 6) should not be present after SetupInitialDuo(5*EpochLength)") } } -// --- TrioAt --- - -func TestTrioAt_GenesisEpoch(t *testing.T) { +func TestDuoAt_GenesisEpoch(t *testing.T) { r, _ := makeRegistry(t) - trio, err := r.TrioAt(0) + duo, err := r.DuoAt(0) if err != nil { - t.Fatalf("TrioAt(0) error: %v", err) - } - if trio.Prev.IsPresent() { - t.Fatalf("TrioAt(0).Prev = %v, want absent for epoch 0", trio.Prev) + t.Fatalf("DuoAt(0) error: %v", err) } - if trio.Current == nil || trio.Current.EpochIndex() != 0 { - t.Fatalf("TrioAt(0).Current.EpochIndex() wrong, want 0") + if duo.Prev.IsPresent() { + t.Fatalf("DuoAt(0).Prev = %v, want absent for epoch 0", duo.Prev) } - if trio.Next == nil || trio.Next.EpochIndex() != 1 { - t.Fatalf("TrioAt(0).Next.EpochIndex() = %v, want 1", trio.Next) + if duo.Current == nil || duo.Current.EpochIndex() != 0 { + t.Fatalf("DuoAt(0).Current.EpochIndex() wrong, want 0") } } -func TestTrioAt_MiddleEpoch(t *testing.T) { +func TestDuoAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) - r.SetupInitialTrio(2 * EpochLength) - trio, err := r.TrioAt(2 * EpochLength) + r.SetupInitialDuo(2 * EpochLength) + duo, err := r.DuoAt(2 * EpochLength) if err != nil { - t.Fatalf("TrioAt(epoch 2) error: %v", err) + t.Fatalf("DuoAt(epoch 2) error: %v", err) } - prev, ok := trio.Prev.Get() + prev, ok := duo.Prev.Get() if !ok || prev.EpochIndex() != 1 { - t.Fatalf("TrioAt(epoch 2).Prev.EpochIndex() wrong, want 1") - } - if trio.Current == nil || trio.Current.EpochIndex() != 2 { - t.Fatalf("TrioAt(epoch 2).Current.EpochIndex() wrong, want 2") + t.Fatalf("DuoAt(epoch 2).Prev.EpochIndex() wrong, want 1") } - if trio.Next == nil || trio.Next.EpochIndex() != 3 { - t.Fatalf("TrioAt(epoch 2).Next.EpochIndex() wrong, want 3") + if duo.Current == nil || duo.Current.EpochIndex() != 2 { + t.Fatalf("DuoAt(epoch 2).Current.EpochIndex() wrong, want 2") } } -func TestTrioAt_ErrorWhenNextMissing(t *testing.T) { - // SetupInitialTrio always seeds Next, so leave a hole by building a bare - // registry with only epoch 0 (skipping NewRegistry's SetupInitialTrio(0)). +func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ types.GenSecretKey(utils.TestRng()).Public(): 1, })) @@ -149,36 +139,35 @@ func TestTrioAt_ErrorWhenNextMissing(t *testing.T) { }), highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), } - _, err := bare.TrioAt(0) + _, err := bare.DuoAt(EpochLength) if err == nil { - t.Fatal("TrioAt(0) expected error when Next epoch not registered, got nil") + t.Fatal("DuoAt(EpochLength) expected error when Current epoch not registered, got nil") } } -func TestWaitForTrio_FastPathAndWait(t *testing.T) { +func TestWaitForDuo_FastPathAndWait(t *testing.T) { r, _ := makeRegistry(t) - // NewRegistry SetupInitialTrio(0) → {0,1}; TrioAt(0) is immediate. - trio, err := r.WaitForTrio(t.Context(), 0) + // NewRegistry SetupInitialDuo(0) → {0}; DuoAt(0) is immediate. + duo, err := r.WaitForDuo(t.Context(), 0) require.NoError(t, err) - require.Equal(t, types.EpochIndex(0), trio.Current.EpochIndex()) + require.Equal(t, types.EpochIndex(0), duo.Current.EpochIndex()) - // Tipcut into epoch 1 needs epoch 2. Seed after WaitForTrio is blocked. + // Tipcut into epoch 1 needs epoch 1 registered. tip := EpochLength - _, err = r.TrioAt(tip) + _, err = r.DuoAt(tip) require.Error(t, err) type result struct { - trio types.EpochTrio - err error + duo types.EpochDuo + err error } done := make(chan result, 1) go func() { - trio, err := r.WaitForTrio(t.Context(), tip) - done <- result{trio, err} + duo, err := r.WaitForDuo(t.Context(), tip) + done <- result{duo, err} }() - r.AdvanceIfNeeded(0) // seeds epoch 2 + r.AdvanceIfNeeded(0) // seeds epoch 1 got := <-done require.NoError(t, got.err) - require.Equal(t, types.EpochIndex(1), got.trio.Current.EpochIndex()) - require.Equal(t, types.EpochIndex(2), got.trio.Next.EpochIndex()) + require.Equal(t, types.EpochIndex(1), got.duo.Current.EpochIndex()) } diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index d90c773e05..b5a06fbc4d 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -17,7 +17,7 @@ func (r *Registry) LatestEpoch() *types.Epoch { // GenRegistry generates a random Registry of the given committee size, // starting at a random epoch index (0–1). Seeds the neighboring epochs -// so the window covers [startEpoch-1, startEpoch, startEpoch+1]. +// so the window covers [startEpoch-1, startEpoch]. // Returns the registry, secret keys, and the starting epoch index. // Intended for use in tests only. func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.EpochIndex) { @@ -29,7 +29,7 @@ func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.E committee := utils.OrPanic1(types.NewCommittee(weights)) firstBlock := types.GenGlobalBlockNumber(rng) % 1000000 // Limit to {0, 1}: GenRegistryAt for either value always includes epoch 0 - // ([0,1] or [0,1,2]), so tests that build CommitQC chains from road index 0 + // ([0] or [0,1]), so tests that build CommitQC chains from road index 0 // can still look up epoch 0 in the window. Higher values would require all // such tests to anchor their chains at startEpoch*EpochLength. startEpoch := types.EpochIndex(rng.Intn(2)) //nolint:gosec @@ -38,7 +38,7 @@ func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.E } // GenRegistryAt generates a Registry of the given committee size centered on startEpoch. -// Seeds [startEpoch-1, startEpoch, startEpoch+1] so TrioAt(startEpoch*EpochLength) works. +// Seeds [startEpoch-1, startEpoch] so DuoAt(startEpoch*EpochLength) works. // Intended for use in tests only. func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Registry, []types.SecretKey) { sks := utils.GenSliceN(rng, size, types.GenSecretKey) @@ -59,7 +59,6 @@ func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumb } // Always seed startEpoch itself (no-op when startEpoch==0, genesis already exists). utils.OrPanic1(registry.makeEpoch(s, startEpoch)) - utils.OrPanic1(registry.makeEpoch(s, startEpoch+1)) s.latest = startEpoch } return registry diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index eb74fe65e9..7a8d24ce88 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -288,8 +288,8 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo if err := r.data.PushAppHash(ctx, b.GlobalNumber, resp.AppHash); err != nil { return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } - // Seed N+2 from the executed CommitQC tipcut (not lagging FinalAppState). - // TODO: real N+2 committee once execution derives it. + // Seed N+1 from the executed CommitQC tipcut (not lagging FinalAppState). + // TODO: real N+1 committee once execution derives it. qc, err := r.data.QC(ctx, b.GlobalNumber) if err != nil { return nil, fmt.Errorf("r.data.QC(%v): %w", b.GlobalNumber, err) @@ -547,6 +547,6 @@ func (r *gigaRouterCommon) RunInboundConn(ctx context.Context, hConn *handshaked // None if the caller should handle it locally. Overridden on // *gigaValidatorRouter to short-circuit self-shard sends. func (r *gigaRouterCommon) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.EpochTrio().Current.Committee().EvmShard(sender) + shardValidator := r.data.EpochDuo().Current.Committee().EvmShard(sender) return utils.Some(r.cfg.ValidatorAddrs[shardValidator].EVMRPC) } diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 590040b68f..98d0dbd5ad 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -79,7 +79,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { // EvmProxy on the validator returns None when the sender's shard owner is // us (handle locally via mempool, no HTTP round-trip to self). func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.EpochTrio().Current.Committee().EvmShard(sender) + shardValidator := r.data.EpochDuo().Current.Committee().EvmShard(sender) if r.validatorKey == shardValidator { return utils.None[*url.URL]() } From 285ff7c1c276ad9f9269bdc79b3686355dca685d Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 19:34:21 -0700 Subject: [PATCH 54/98] refactor(autobahn): drop registry from consensus inner; seed N+2 on last road Keep epoch transitions explicit via State, and restore SetupInitialDuo/{0,1} plus AdvanceIfNeeded(N+2) only when roadIndex+1 == epoch Next. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner_test.go | 2 +- .../internal/autobahn/avail/state_test.go | 32 ++++++++--------- .../internal/autobahn/consensus/inner.go | 34 ++++++++++++------- .../internal/autobahn/consensus/state.go | 4 +++ .../internal/autobahn/data/state.go | 2 +- .../internal/autobahn/epoch/registry.go | 34 +++++++++++-------- .../internal/autobahn/epoch/registry_test.go | 32 +++++++++-------- .../internal/p2p/giga_router_common.go | 4 +-- .../p2p/giga_router_validator_test.go | 7 ++-- 9 files changed, 87 insertions(+), 64 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 7076a34257..16f7aa54f6 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -117,7 +117,7 @@ func TestNewInnerLoadedNoAnchor(t *testing.T) { func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) - registry.AdvanceIfNeeded(0) + // SetupInitialDuo(0) already seeds epoch 1. duo := utils.OrPanic1(registry.DuoAt(epoch.EpochLength)) require.Equal(t, types.EpochIndex(1), duo.Current.EpochIndex()) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 2506f753f6..943f27b3e2 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -879,9 +879,7 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) // Center the operating window on epoch 2 so road 0 is outside Prev|Current. - for _, road := range []types.RoadIndex{0, epoch.EpochLength, 2 * epoch.EpochLength} { - registry.AdvanceIfNeeded(road) - } + registry.SetupInitialDuo(2 * epoch.EpochLength) farDuo := utils.OrPanic1(registry.DuoAt(2 * epoch.EpochLength)) for inner := range state.inner.Lock() { inner.epochDuo.Store(farDuo) @@ -894,8 +892,7 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) - registry.AdvanceIfNeeded(0) // seeds epoch 1 - registry.AdvanceIfNeeded(epoch.EpochLength) // seeds epoch 2 + registry.SetupInitialDuo(2 * epoch.EpochLength) // {1,2,3}; genesis already has 0 future := utils.OrPanic1(registry.EpochAt(2 * epoch.EpochLength)) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, @@ -929,9 +926,7 @@ func TestWaitEpochForRoadFutureBlocks(t *testing.T) { func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) - for _, road := range []types.RoadIndex{0, epoch.EpochLength, 2 * epoch.EpochLength} { - registry.AdvanceIfNeeded(road) - } + registry.SetupInitialDuo(2 * epoch.EpochLength) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err := NewState(keys[0], ds, utils.None[string]()) @@ -991,21 +986,22 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 3, 0) - // GenRegistryAt(0) / SetupInitialDuo(0) → epoch {0} only. - ep0, err := registry.DuoAt(0) + // GenRegistryAt(0) / SetupInitialDuo(0) → epochs {0,1}; epoch 2 is unseeded + // until the last road of epoch 0 is executed (AdvanceIfNeeded) or a later SetupInitialDuo. + ep1, err := registry.DuoAt(epoch.EpochLength) require.NoError(t, err) - tip1 := ep0.Current.RoadRange().Next // first road of epoch 1 - require.Equal(t, types.RoadIndex(epoch.EpochLength), tip1) - _, err = registry.DuoAt(tip1) - require.Error(t, err, "DuoAt(epoch-1 tip) must fail without epoch 1") + tip2 := ep1.Current.RoadRange().Next // first road of epoch 2 + require.Equal(t, types.RoadIndex(2*epoch.EpochLength), tip2) + _, err = registry.DuoAt(tip2) + require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") // Same seeding NewState does before DuoAt(commit tip). - registry.SetupInitialDuo(tip1) - tipDuo1, err := registry.DuoAt(tip1) + registry.SetupInitialDuo(tip2) + tipDuo2, err := registry.DuoAt(tip2) require.NoError(t, err) - require.Equal(t, types.EpochIndex(1), tipDuo1.Current.EpochIndex()) + require.Equal(t, types.EpochIndex(2), tipDuo2.Current.EpochIndex()) - _, err = newInner(tipDuo1, utils.None[*loadedAvailState]()) + _, err = newInner(tipDuo2, utils.None[*loadedAvailState]()) require.Error(t, err, "epoch > 0 requires a prune anchor") } diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 9b8770c6e1..c5c22ff2af 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -92,10 +92,11 @@ var logger = seilog.NewLogger("tendermint", "internal", "autobahn", "consensus") // Persisted state file prefix for consensus inner state. const innerFile = "inner" +// inner holds no registry: the epoch is provided from outside (newInner / +// pushCommitQC on State), and epoch transitions are explicit. type inner struct { persistedInner - registry *epoch.Registry - epoch *types.Epoch + epoch *types.Epoch } // View returns the current view, embedding the epoch's index. @@ -139,7 +140,7 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - return inner{persistedInner: persisted, registry: registry, epoch: viewEpoch}, nil + return inner{persistedInner: persisted, epoch: viewEpoch}, nil } func (s *State) pushCommitQC(qc *types.CommitQC) error { @@ -155,15 +156,24 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } - // Invariant: N+1 is registered before this CommitQC (setup / AdvanceIfNeeded). - // Hard-error if missing — do not WaitForDuo like avail/data do for N+1. - nextEp, err := i.registry.EpochAt(qc.Proposal().Index() + 1) - if err != nil { - logger.Error("next epoch not in registry at CommitQC boundary", - "road", qc.Proposal().Index()+1) - return fmt.Errorf("EpochAt(%d): %w", qc.Proposal().Index()+1, err) + // Epoch for the next view. Within an epoch this is i.epoch; on the + // last CommitQC of the epoch it is an explicit transition, resolved + // from the registry held by State (inner holds no registry). + nextRoad := qc.Proposal().Index() + 1 + nextEp := i.epoch + if !i.epoch.RoadRange().Has(nextRoad) { + // Invariant: N+1 is registered before this CommitQC (setup / + // AdvanceIfNeeded). Hard-error if missing — do not WaitForDuo + // like avail/data do. + var err error + nextEp, err = s.registry.EpochAt(nextRoad) + if err != nil { + logger.Error("next epoch not in registry at CommitQC boundary", + "road", nextRoad) + return fmt.Errorf("EpochAt(%d): %w", nextRoad, err) + } } - iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, registry: i.registry, epoch: nextEp}) + iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, epoch: nextEp}) } return nil } @@ -190,7 +200,7 @@ func (s *State) pushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { return nil } // TimeoutQC advances view number; clear votes and prepareQC. Epoch unchanged. - isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, registry: i.registry, epoch: i.epoch}) + isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, epoch: i.epoch}) } return nil } diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index a06d2c1697..ee6773e4da 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -9,6 +9,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -39,6 +40,8 @@ type Config struct { type State struct { cfg *Config avail *avail.State + // registry resolves epochs for explicit transitions (inner holds no registry). + registry *epoch.Registry // metrics *Metrics inner utils.Mutex[*utils.AtomicSend[inner]] innerRecv utils.AtomicRecv[inner] @@ -115,6 +118,7 @@ func newState( cfg: cfg, // metrics: NewMetrics(), avail: availState, + registry: data.Registry(), inner: utils.NewMutex(innerSend), innerRecv: innerSend.Subscribe(), persister: pers, diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 6a1edc334d..8df454dc2f 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -301,7 +301,7 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if dataFirst > cfg.Registry.FirstBlock() { inner.skipTo(dataFirst) } - // Seed {N-1, N} around the WAL tip for QC verify / DuoAt. + // Seed {N-1, N, N+1} around the WAL tip for QC verify / DuoAt. // Invariant: CommitQC WAL span behind that tip is within this window // (pruned vs latest AppQC today). TODO: verified snapshot / state-sync. loadedQCs := dataWAL.CommitQCs.ConsumeLoaded() diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index d28a445834..67e800236b 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -27,8 +27,8 @@ type Registry struct { } // NewRegistry creates a Registry with the genesis committee. -// SetupInitialDuo(0) seeds epoch 0; epoch 1 is registered by AdvanceIfNeeded -// (or WaitForDuo at the first boundary). +// SetupInitialDuo(0) seeds epochs {0,1}; epoch 2 is registered when the last +// road of epoch 0 is executed (AdvanceIfNeeded). func NewRegistry( committee *types.Committee, firstBlock types.GlobalBlockNumber, @@ -48,9 +48,10 @@ func NewRegistry( return r, nil } -// SetupInitialDuo registers placeholder epochs {N-1, N} around roadIndex +// SetupInitialDuo registers placeholder epochs {N-1..N+1} around roadIndex // (clamped at 0) with the genesis committee. Idempotent for existing entries. -// The next epoch is seeded later by AdvanceIfNeeded / WaitForDuo at the boundary. +// N+1 is included so tipcut into the next epoch works before the last road of +// N has been executed; AdvanceIfNeeded then seeds N+2 on that last road. // TODO: replace with verified snapshot / state-sync epoch info. func (r *Registry) SetupInitialDuo(roadIndex types.RoadIndex) { n := types.EpochIndex(roadIndex / EpochLength) @@ -58,8 +59,9 @@ func (r *Registry) SetupInitialDuo(roadIndex types.RoadIndex) { if n >= 1 { first = n - 1 } + last := n + 1 for s := range r.state.Lock() { - for idx := first; idx <= n; idx++ { + for idx := first; idx <= last; idx++ { if _, ok := s.m[idx]; ok { continue } @@ -111,21 +113,25 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type return epoch, nil } -// AdvanceIfNeeded seeds epoch N+1 when any road in epoch N is executed. -// Invariant: by the time Tipcut needs DuoAt(N.Next), N+1 is either already -// registered or waiters use WaitForDuo until this runs. -// TODO: pass the real N+1 committee once execution derives it. +// AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed. +// Earlier roads in the epoch are a no-op. Committee for N+2 is derived from +// finishing epoch N; WaitForDuo covers cases where setup has not yet filled N+1. +// TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { - nextIdx := types.EpochIndex(roadIndex/EpochLength) + 1 - // Fast path: epoch already seeded (common after the first block of the epoch). + next := types.RoadIndex(types.EpochIndex(roadIndex/EpochLength)+1) * EpochLength + if roadIndex+1 != next { + return // not the last road of its epoch + } + nextNextIdx := types.EpochIndex(roadIndex/EpochLength) + 2 + // Fast path: epoch already seeded. for s := range r.state.RLock() { - if _, ok := s.m[nextIdx]; ok { + if _, ok := s.m[nextNextIdx]; ok { return } } for s := range r.state.Lock() { - if _, ok := s.m[nextIdx]; !ok { - _, _ = r.makeEpoch(s, nextIdx) //nolint:errcheck // genesis always present + if _, ok := s.m[nextNextIdx]; !ok { + _, _ = r.makeEpoch(s, nextNextIdx) //nolint:errcheck // genesis always present } } } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 03fa4112aa..e961b0f18d 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -65,22 +65,26 @@ func TestEpochAt_ErrorIfNotRegistered(t *testing.T) { func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { r, _ := makeRegistry(t) - // NewRegistry already seeds epoch 1. AdvanceIfNeeded is idempotent. + // NewRegistry already seeds {0,1}. Only the last road of epoch 0 seeds epoch 2. r.AdvanceIfNeeded(0) - ep, err := r.EpochAt(EpochLength) + if _, err := r.EpochAt(2 * EpochLength); err == nil { + t.Fatal("AdvanceIfNeeded(0) must not seed epoch 2") + } + r.AdvanceIfNeeded(EpochLength - 1) + ep, err := r.EpochAt(2 * EpochLength) if err != nil { - t.Fatalf("EpochAt(EpochLength) after AdvanceIfNeeded: %v", err) + t.Fatalf("EpochAt(2*EpochLength) after last road of epoch 0: %v", err) } - if ep.EpochIndex() != 1 { - t.Fatalf("EpochAt(EpochLength).EpochIndex() = %d, want 1", ep.EpochIndex()) + if ep.EpochIndex() != 2 { + t.Fatalf("EpochAt(2*EpochLength).EpochIndex() = %d, want 2", ep.EpochIndex()) } } func TestSetupInitialDuo_WindowAroundTip(t *testing.T) { r, _ := makeRegistry(t) - // N=5 → {4,5}. Epoch 0 already present from NewRegistry. + // N=5 → {4,5,6}. Epoch 0 already present from NewRegistry. r.SetupInitialDuo(5 * EpochLength) - for _, idx := range []types.EpochIndex{4, 5} { + for _, idx := range []types.EpochIndex{4, 5, 6} { ep, err := r.EpochAt(types.RoadIndex(idx) * EpochLength) if err != nil { t.Fatalf("EpochAt(epoch %d) after SetupInitialDuo: %v", idx, err) @@ -92,8 +96,8 @@ func TestSetupInitialDuo_WindowAroundTip(t *testing.T) { if _, err := r.EpochAt(3 * EpochLength); err == nil { t.Fatal("EpochAt(epoch 3) should not be present after SetupInitialDuo(5*EpochLength)") } - if _, err := r.EpochAt(6 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 6) should not be present after SetupInitialDuo(5*EpochLength)") + if _, err := r.EpochAt(7 * EpochLength); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present after SetupInitialDuo(5*EpochLength)") } } @@ -147,13 +151,13 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { func TestWaitForDuo_FastPathAndWait(t *testing.T) { r, _ := makeRegistry(t) - // NewRegistry SetupInitialDuo(0) → {0}; DuoAt(0) is immediate. + // NewRegistry SetupInitialDuo(0) → {0,1}; DuoAt(0) is immediate. duo, err := r.WaitForDuo(t.Context(), 0) require.NoError(t, err) require.Equal(t, types.EpochIndex(0), duo.Current.EpochIndex()) - // Tipcut into epoch 1 needs epoch 1 registered. - tip := EpochLength + // Tipcut into epoch 2 needs epoch 2 registered (seeded by executing epoch 0). + tip := 2 * EpochLength _, err = r.DuoAt(tip) require.Error(t, err) @@ -166,8 +170,8 @@ func TestWaitForDuo_FastPathAndWait(t *testing.T) { duo, err := r.WaitForDuo(t.Context(), tip) done <- result{duo, err} }() - r.AdvanceIfNeeded(0) // seeds epoch 1 + r.AdvanceIfNeeded(EpochLength - 1) // last road of epoch 0 seeds epoch 2 got := <-done require.NoError(t, got.err) - require.Equal(t, types.EpochIndex(1), got.duo.Current.EpochIndex()) + require.Equal(t, types.EpochIndex(2), got.duo.Current.EpochIndex()) } diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 7a8d24ce88..1e066536f0 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -288,8 +288,8 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo if err := r.data.PushAppHash(ctx, b.GlobalNumber, resp.AppHash); err != nil { return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } - // Seed N+1 from the executed CommitQC tipcut (not lagging FinalAppState). - // TODO: real N+1 committee once execution derives it. + // Seed N+2 when executing the last road of an epoch (AdvanceIfNeeded no-ops otherwise). + // TODO: real N+2 committee once execution derives it. qc, err := r.data.QC(ctx, b.GlobalNumber) if err != nil { return nil, fmt.Errorf("r.data.QC(%v): %w", b.GlobalNumber, err) diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index ef7d538395..d78be5e365 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -188,9 +188,12 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { } require.Equal(t, gb.Payload.Txs(), rbBytes, "router[0].BlockByNumber(%v).Block.Data.Txs ≠ data.GlobalBlock(%v).Payload.Txs", h, h) } - // executeBlock → AdvanceIfNeeded must seed epoch 2 after epoch-0 execution. + // Short run stays in early epoch 0; AdvanceIfNeeded only seeds N+2 on the + // last road, so epoch 2 must still be absent while {0,1} remain from setup. _, err := giga0.data.Registry().EpochAt(2 * epoch.EpochLength) - require.NoError(t, err, "executeBlock should AdvanceIfNeeded so epoch 2 is registered") + require.Error(t, err, "epoch 2 should not be seeded before last road of epoch 0") + _, err = giga0.data.Registry().EpochAt(epoch.EpochLength) + require.NoError(t, err, "SetupInitialDuo(0) should have seeded epoch 1") return nil }) require.NoError(t, err) From f8f48321d614d53145c2d0a5a03100d068be6a72 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 20:11:25 -0700 Subject: [PATCH 55/98] fix(autobahn): reject CommitQC whose epoch disagrees with consensus inner Make the sequenced-tip invariant explicit before Verify so we hard-error instead of re-resolving the epoch from the registry. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/consensus/inner.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index c5c22ff2af..7e6dff8f1c 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -148,6 +148,13 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } + // Avail sequences CommitQCs before publishing LastCommitQC, so the tip + // must belong to the epoch consensus already holds. Mismatch is fatal — + // do not re-resolve via registry (that would paper over a sequencing bug). + if got, want := qc.Proposal().EpochIndex(), i.epoch.EpochIndex(); got != want { + return fmt.Errorf("CommitQC epoch %d != inner epoch %d (road %d)", + got, want, qc.Proposal().Index()) + } if err := qc.Verify(i.epoch); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } From edeaec79ec16166f26113d00dd8622f053db362f Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 20:46:33 -0700 Subject: [PATCH 56/98] feat(autobahn): allow AppQC from Prev epoch in proposals via ViewSpec.Epochs Carry an EpochDuo (Prev|Current) on ViewSpec and consensus inner so a fresh AppQC lagging the proposing epoch by one verifies against its own committee. Also gate avail PushCommitQC's AppQC wait on the verified epoch, not the unverified qc.Proposal().EpochIndex(). Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 11 + sei-tendermint/autobahn/types/proposal.go | 38 +-- .../autobahn/types/proposal_test.go | 218 ++++++++++-------- sei-tendermint/autobahn/types/testonly.go | 2 +- .../autobahn/types/wireguard_test.go | 2 +- .../internal/autobahn/avail/state.go | 15 +- .../internal/autobahn/avail/state_test.go | 2 +- .../internal/autobahn/consensus/inner.go | 50 ++-- .../consensus/persist/commitqcs_test.go | 2 +- .../consensus/persist/fullcommitqcs_test.go | 6 +- .../autobahn/consensus/persisted_inner.go | 2 +- .../internal/autobahn/consensus/state.go | 26 +-- .../internal/autobahn/data/state_test.go | 2 +- .../internal/autobahn/data/testonly.go | 2 +- 14 files changed, 221 insertions(+), 157 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index e69e247f37..4b0ff3187c 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -31,6 +31,17 @@ func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { return nil, fmt.Errorf("road %d not in window %v", roadIdx, w) } +// EpochForIndex returns Current or Prev by epoch index. +func (w EpochDuo) EpochForIndex(idx EpochIndex) (*Epoch, error) { + if w.Current != nil && w.Current.EpochIndex() == idx { + return w.Current, nil + } + if prev, ok := w.Prev.Get(); ok && prev.EpochIndex() == idx { + return prev, nil + } + return nil, fmt.Errorf("epoch %d not in window %v", idx, w) +} + // String returns a compact description of the epoch indices in the window. func (w EpochDuo) String() string { s := "epochs [" diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 8877d06d83..5c4b6d9f36 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -122,17 +122,21 @@ func (v View) Next() View { } // ViewSpec is the full local context for starting a view: justification QCs plus -// the epoch active at that view. Epoch is required; View(), NextGlobalBlock(), and -// NextTimestamp() panic if it is nil. +// the Prev|Current epoch window. Epochs.Current is required; View(), +// NextGlobalBlock(), and NextTimestamp() panic if it is nil. Prev is used to +// verify an AppQC that lags the proposing epoch by one. type ViewSpec struct { // WARNING: currently we have implicit assumption that // TimeoutQC.View().Index == CommitQC.Index.Next(), // I.e. that TimeoutQC comes from the expected consensus instance. CommitQC utils.Option[*CommitQC] TimeoutQC utils.Option[*TimeoutQC] - Epoch *Epoch + Epochs EpochDuo } +// Epoch is the proposing/voting epoch (Epochs.Current). +func (vs *ViewSpec) Epoch() *Epoch { return vs.Epochs.Current } + // NextGlobalBlock returns the first global block number expected in the next proposal. // CommitQC is None only at global block 0 (genesis), in which case it returns Epoch[0].FirstBlock. // For all other views, including the first view of a non-genesis epoch, CommitQC is present and it returns CommitQC.GlobalRange().Next. @@ -140,24 +144,24 @@ func (vs *ViewSpec) NextGlobalBlock() GlobalBlockNumber { if cQC, ok := vs.CommitQC.Get(); ok { return cQC.GlobalRange().Next } - return vs.Epoch.FirstBlock() + return vs.Epoch().FirstBlock() } // View is the view justified by vs. func (vs *ViewSpec) View() View { idx := NextIndexOpt(vs.CommitQC) if view := NextViewOpt(vs.TimeoutQC); view.Index == idx { - view.EpochIndex = vs.Epoch.EpochIndex() + view.EpochIndex = vs.Epoch().EpochIndex() return view } - return View{Index: idx, Number: 0, EpochIndex: vs.Epoch.EpochIndex()} + return View{Index: idx, Number: 0, EpochIndex: vs.Epoch().EpochIndex()} } func (vs *ViewSpec) NextTimestamp() time.Time { if cQC, ok := vs.CommitQC.Get(); ok { return cQC.Proposal().NextTimestamp() } - return vs.Epoch.FirstTimestamp() + return vs.Epoch().FirstTimestamp() } // Proposal is the road tipcut proposal. @@ -295,7 +299,7 @@ func NewProposal( laneQCs map[LaneID]*LaneQC, appQC utils.Option[*AppQC], ) (*FullProposal, error) { - committee := viewSpec.Epoch.Committee() + committee := viewSpec.Epoch().Committee() if got, want := key.Public(), committee.Leader(viewSpec.View()); got != want { return nil, fmt.Errorf("key %q is not the leader %q for view %v", got, want, viewSpec.View()) } @@ -403,8 +407,10 @@ func (m *FullProposal) TimeoutQC() utils.Option[*TimeoutQC] { } // Verify verifies the FullProposal against the current view. +// AppQC may lag the proposing epoch by one; its committee is resolved from +// vs.Epochs (Prev|Current). func (m *FullProposal) Verify(vs ViewSpec) error { - c := vs.Epoch.Committee() + c := vs.Epoch().Committee() return scope.Parallel(func(s scope.ParallelScope) error { // Does the view match? if got, want := m.proposal.Msg().View(), vs.View(); got != want { @@ -432,7 +438,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { // Verify timeoutQC. if tQC, ok := m.timeoutQC.Get(); ok { s.Spawn(func() error { - if err := tQC.Verify(vs.Epoch, vs.CommitQC); err != nil { + if err := tQC.Verify(vs.Epoch(), vs.CommitQC); err != nil { return fmt.Errorf("timeoutQC: %w", err) } return nil @@ -451,7 +457,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } // Verify the proposal's epoch binding, road range, lane structure, and membership. proposal := m.proposal.Msg() - if err := proposal.Verify(vs.Epoch); err != nil { + if err := proposal.Verify(vs.Epoch()); err != nil { return fmt.Errorf("proposal: %w", err) } // Verify each lane range against the previous commitQC and its laneQC justification. @@ -490,9 +496,11 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } } else { app, _ := m.proposal.Msg().App().Get() - // TODO: relax to allow current_epoch-1 once epoch transitions are wired up. - if got, want := app.EpochIndex(), m.proposal.Msg().EpochIndex(); got != want { - return fmt.Errorf("app epoch_index %d != proposal epoch_index %d", got, want) + // AppQC may be from Current or Prev (lag by one). Resolve its + // committee from vs.Epochs — not from Current alone. + appEp, err := vs.Epochs.EpochForIndex(app.EpochIndex()) + if err != nil { + return fmt.Errorf("app epoch_index %d: %w", app.EpochIndex(), err) } appQC, ok := m.appQC.Get() if !ok { @@ -502,7 +510,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { return errors.New("appQC doesn't match the proposal") } s.Spawn(func() error { - if err := appQC.Verify(c); err != nil { + if err := appQC.Verify(appEp.Committee()); err != nil { return fmt.Errorf("appQC: %w", err) } return nil diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 408ed5f9d6..514c306f81 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -75,7 +75,7 @@ func TestProposalVerifyFreshEmptyRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -86,7 +86,7 @@ func TestProposalVerifyFreshWithBlocks(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) // Produce a LaneQC for the proposer's lane. @@ -102,7 +102,7 @@ func TestNewProposalRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testing. rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -122,7 +122,7 @@ func TestProposalBlockTimestampStrictlyMonotone(t *testing.T) { committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) firstBlock := ep.FirstBlock() - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() @@ -145,7 +145,7 @@ func TestProposalBlockTimestampStrictlyMonotone(t *testing.T) { require.True(t, second0.Before(third0), "block timestamps within one proposal must be strictly increasing") commitQC0 := makeCommitQCFromProposal(keys, firstProposal) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epoch: ep} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: EpochDuo{Current: ep}} proposer1 := leaderKey(committee, keys, vs1.View()) secondProposal := utils.OrPanic1(NewProposal( @@ -170,13 +170,13 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { committee, keys := GenCommittee(rng, 4) genesisTimestamp := time.Now() ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), genesisTimestamp, committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} k := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, nil, utils.None[*AppQC]())) require.NoError(t, fp.Verify(vs)) vsLater := vs - vsLater.Epoch = NewEpoch(ep.EpochIndex(), ep.RoadRange(), fp.Proposal().Msg().Timestamp().Add(time.Nanosecond), committee, ep.FirstBlock()) + vsLater.Epochs.Current = NewEpoch(ep.EpochIndex(), ep.RoadRange(), fp.Proposal().Msg().Timestamp().Add(time.Nanosecond), committee, ep.FirstBlock()) require.Error(t, fp.Verify(vsLater)) }) @@ -184,7 +184,7 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() lQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -202,8 +202,8 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { utils.None[*AppQC](), )) - vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epoch: ep} - vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epoch: ep} + vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epochs: EpochDuo{Current: ep}} + vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epochs: EpochDuo{Current: ep}} proposer1 := leaderKey(committee, keys, vs1a.View()) fp1a := utils.OrPanic1(NewProposal( @@ -224,13 +224,13 @@ func TestProposalVerifyRejectsViewMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a valid proposal at genesis view (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} leader0 := leaderKey(committee, keys, vs0.View()) fp := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) // Verify it against a different ViewSpec (view 1, 0). commitQC := makeCommitQCFromProposal(keys, fp) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epoch: ep} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epochs: EpochDuo{Current: ep}} err := fp.Verify(vs1) require.Error(t, err) } @@ -239,7 +239,7 @@ func TestProposalVerifyRejectsForgedSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) // Build two valid proposals with different timestamps. @@ -256,7 +256,7 @@ func TestProposalVerifyRejectsWrongProposer(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} correctLeader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -283,7 +283,7 @@ func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} // no timeoutQC + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} // no timeoutQC proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -309,7 +309,7 @@ func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -350,7 +350,7 @@ func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -379,7 +379,7 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -406,7 +406,7 @@ func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -434,7 +434,7 @@ func TestProposalVerifyRejectsMissingLaneQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -456,7 +456,7 @@ func TestProposalVerifyRejectsLaneQCBlockNumberMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -480,7 +480,7 @@ func TestProposalVerifyRejectsInvalidLaneQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -541,7 +541,7 @@ func makeFullProposal( appQC utils.Option[*AppQC], ) *FullProposal { committee := ep.Committee() - vs := ViewSpec{CommitQC: prev, Epoch: ep} + vs := ViewSpec{CommitQC: prev, Epochs: EpochDuo{Current: ep}} return utils.OrPanic1(NewProposal( leaderKey(committee, keys, vs.View()), vs, time.Now(), @@ -577,7 +577,7 @@ func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { fp2b := makeFullProposal(ep, keys, utils.Some(commitQC1b), nil, utils.None[*AppQC]()) // We construct the invalid proposal by constructing 2 alternative futures: one with appQC, one without. - vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epoch: ep} + vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epochs: EpochDuo{Current: ep}} require.NoError(t, fp2a.Verify(vs)) require.Error(t, fp2b.Verify(vs)) } @@ -586,7 +586,7 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} // no previous commitQC, so app starts at None + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} // no previous commitQC, so app starts at None leader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -604,49 +604,69 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { } func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) // firstBlock >= 1, so firstBlock-1 is valid - vs := ViewSpec{Epoch: ep} // no previous commitQC - leader := leaderKey(committee, keys, vs.View()) + for _, tc := range []struct { + name string + appEpoch EpochIndex + }{ + {"current", 1}, + {"prev", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + prev := NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1) + current := NewEpoch(1, OpenRoadRange(), time.Time{}, committee, 1) + vs := ViewSpec{Epochs: EpochDuo{Prev: utils.Some(prev), Current: current}} + leader := leaderKey(committee, keys, vs.View()) - // Build a valid proposal with an AppQC, then strip it. - goodAppQC := makeAppQCFor(keys, ep.FirstBlock()-1, 0, GenAppHash(rng), ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + // Fresh AppQC: globalNumber 0 < FirstBlock 1. + goodAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) - tamperedFP := &FullProposal{ - proposal: fp.proposal, + tamperedFP := &FullProposal{proposal: fp.proposal} + require.Error(t, tamperedFP.Verify(vs)) + }) } - err := tamperedFP.Verify(vs) - require.Error(t, err) } func TestProposalVerifyRejectsAppQCMismatch(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} - leader := leaderKey(committee, keys, vs.View()) - - // Build a valid proposal with an AppQC, then swap in a different one. - goodAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) - - differentAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) - tamperedFP := &FullProposal{ - proposal: fp.proposal, - appQC: utils.Some(differentAppQC), + for _, tc := range []struct { + name string + appEpoch EpochIndex + }{ + {"current", 1}, + {"prev", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + prev := NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1) + current := NewEpoch(1, OpenRoadRange(), time.Time{}, committee, 1) + vs := ViewSpec{Epochs: EpochDuo{Prev: utils.Some(prev), Current: current}} + leader := leaderKey(committee, keys, vs.View()) + + goodAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + + differentAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + tamperedFP := &FullProposal{ + proposal: fp.proposal, + appQC: utils.Some(differentAppQC), + } + require.Error(t, tamperedFP.Verify(vs)) + }) } - err := tamperedFP.Verify(vs) - require.Error(t, err) } func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - // firstBlock=1 so NextGlobalBlock()=1 and globalNumber=0 is a valid app target. - vs := ViewSpec{Epoch: NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1)} + // OpenRoadRange so View index 0 is valid for both epochs; this test only + // covers AppQC epoch resolution via ViewSpec.Epochs. + prev := NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1) + current := NewEpoch(1, OpenRoadRange(), time.Time{}, committee, 1) + vs := ViewSpec{Epochs: EpochDuo{Prev: utils.Some(prev), Current: current}} leader := leaderKey(committee, keys, vs.View()) makeAppQCWithEpoch := func(epochIdx EpochIndex) *AppQC { @@ -659,45 +679,59 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { return NewAppQC(votes) } - // app epoch matches proposal epoch — accepted. - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(0)))) + // AppQC from Current — accepted. + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(1)))) require.NoError(t, fp.Verify(vs)) - // app epoch differs from proposal epoch — rejected. - fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(1)))) + // AppQC from Prev (N-1) — accepted. + fpPrev := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(0)))) + require.NoError(t, fpPrev.Verify(vs)) + + // AppQC outside the duo — rejected. + fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(2)))) require.Error(t, fpWrong.Verify(vs)) } func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} - leader := leaderKey(committee, keys, vs.View()) - - appHash := GenAppHash(rng) - goodAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, appHash, ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) - - // Swap in an AppQC signed by NON-committee keys (same hash). - otherKeys := make([]SecretKey, len(keys)) - for i := range otherKeys { - otherKeys[i] = GenSecretKey(rng) - } - badAppQC := makeAppQCFor(otherKeys, ep.FirstBlock(), 0, appHash, ep.EpochIndex()) - tamperedFP := &FullProposal{ - proposal: fp.proposal, - appQC: utils.Some(badAppQC), + for _, tc := range []struct { + name string + appEpoch EpochIndex + }{ + {"current", 1}, + {"prev", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + prev := NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1) + current := NewEpoch(1, OpenRoadRange(), time.Time{}, committee, 1) + vs := ViewSpec{Epochs: EpochDuo{Prev: utils.Some(prev), Current: current}} + leader := leaderKey(committee, keys, vs.View()) + + appHash := GenAppHash(rng) + goodAppQC := makeAppQCFor(keys, 0, 0, appHash, tc.appEpoch) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + + // Swap in an AppQC signed by NON-committee keys (same hash). + otherKeys := make([]SecretKey, len(keys)) + for i := range otherKeys { + otherKeys[i] = GenSecretKey(rng) + } + badAppQC := makeAppQCFor(otherKeys, 0, 0, appHash, tc.appEpoch) + tamperedFP := &FullProposal{ + proposal: fp.proposal, + appQC: utils.Some(badAppQC), + } + require.Error(t, tamperedFP.Verify(vs)) + }) } - err := tamperedFP.Verify(vs) - require.Error(t, err) } func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -726,7 +760,7 @@ func TestProposalVerifyValidReproposal(t *testing.T) { // firstBlock > 0 ensures a reproposal bug that passes GlobalRange().First // (= sum(lane.First)+firstBlock) instead of firstBlock would be caught. ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} leader0 := leaderKey(committee, keys, vs0.View()) lane := committee.Leader(vs0.View()) laneQC0 := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -747,7 +781,7 @@ func TestProposalVerifyValidReproposal(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuo{Current: ep}} require.Equal(t, View{Index: 0, Number: 1, EpochIndex: ep.EpochIndex()}, vs1.View()) leader1 := leaderKey(committee, keys, vs1.View()) @@ -764,7 +798,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) @@ -780,7 +814,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuo{Current: ep}} leader1 := leaderKey(committee, keys, vs1.View()) // Create a valid reproposal, then tamper it with unnecessary laneQCs. @@ -803,7 +837,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) @@ -819,7 +853,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuo{Current: ep}} leader1 := leaderKey(committee, keys, vs1.View()) // Build the valid reproposal, then tamper its timestamp to get a different hash. @@ -855,7 +889,7 @@ func TestProposalVerifyRejectsInvalidTimeoutQCSignature(t *testing.T) { } badTimeoutQC := NewTimeoutQC(timeoutVotes) - vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epoch: ep} + vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epochs: EpochDuo{Current: ep}} leader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.None[*AppQC]())) @@ -870,17 +904,17 @@ func TestViewSpecViewStampsEpochIndex(t *testing.T) { epochIdx := EpochIndex(7) ep := NewEpoch(epochIdx, OpenRoadRange(), time.Time{}, committee, 0) - // Without TimeoutQC: epoch index must come from vs.Epoch. - vs0 := ViewSpec{Epoch: ep} + // Without TimeoutQC: epoch index must come from vs.Epoch(). + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} if got := vs0.View().EpochIndex; got != epochIdx { t.Fatalf("no-TimeoutQC path: EpochIndex = %d, want %d", got, epochIdx) } - // With TimeoutQC: epoch index must still come from vs.Epoch, not the QC's stored value. + // With TimeoutQC: epoch index must still come from vs.Epoch(), not the QC's stored value. tqc := NewTimeoutQC([]*FullTimeoutVote{ NewFullTimeoutVote(keys[0], View{EpochIndex: 0}, utils.None[*PrepareQC]()), }) - vs1 := ViewSpec{TimeoutQC: utils.Some(tqc), Epoch: ep} + vs1 := ViewSpec{TimeoutQC: utils.Some(tqc), Epochs: EpochDuo{Current: ep}} if got := vs1.View().EpochIndex; got != epochIdx { t.Fatalf("TimeoutQC path: EpochIndex = %d, want %d", got, epochIdx) } diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index e573bb78c4..151e8f960f 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -21,7 +21,7 @@ func BuildCommitQC( laneQCs map[LaneID]*LaneQC, appQC utils.Option[*AppQC], ) *CommitQC { - vs := ViewSpec{CommitQC: prev, Epoch: epoch} + vs := ViewSpec{CommitQC: prev, Epochs: EpochDuo{Current: epoch}} leader := epoch.Committee().Leader(vs.View()) var leaderKey SecretKey for _, k := range keys { diff --git a/sei-tendermint/autobahn/types/wireguard_test.go b/sei-tendermint/autobahn/types/wireguard_test.go index cbebd32535..b45ab8df0a 100644 --- a/sei-tendermint/autobahn/types/wireguard_test.go +++ b/sei-tendermint/autobahn/types/wireguard_test.go @@ -197,7 +197,7 @@ func TestFullProposalWireguardAcceptsMaxValidators(t *testing.T) { } proposal, err := NewProposal( secretKeyFor(keys, committee.Leader(View{})), - ViewSpec{Epoch: NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 0)}, + ViewSpec{Epochs: EpochDuo{Current: NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 0)}}, time.Unix(1, 2), laneQCs, utils.None[*AppQC](), diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 278f9fe290..7293fc0102 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -324,13 +324,6 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - // Epochs are numbered from 0 (unlike global block numbers). Epoch 0 has no - // prior AppQC to wait for; N+1 requires AppQC of epoch N. - if qcEpoch := qc.Proposal().EpochIndex(); qcEpoch > 0 { - if err := s.waitForAppQCEpoch(ctx, qcEpoch-1); err != nil { - return err - } - } duo := s.epochDuo.Load() // CommitQCs are admitted for Current only; new-committee traffic starts // after the boundary advances the duo. @@ -343,6 +336,14 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } + // Gate on the verified epoch; don't blindly trust qc.Proposal().EpochIndex(). + // Epochs are numbered from 0 (unlike global block numbers): epoch 0 has no + // prior AppQC; N+1 requires AppQC of N. + if epochIdx := ep.EpochIndex(); epochIdx > 0 { + if err := s.waitForAppQCEpoch(ctx, epochIdx-1); err != nil { + return err + } + } // Boundary: switch to the next epoch on Current's last CommitQC. // Resolve next duo off-lock (WaitForDuo). diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 943f27b3e2..a1ea2b8496 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -333,7 +333,7 @@ func TestStateMismatchedQCs(t *testing.T) { // Helper to create a CommitQC for a specific index makeQC := func(prev utils.Option[*types.CommitQC], laneQCs map[types.LaneID]*types.LaneQC) *types.CommitQC { - vs := types.ViewSpec{CommitQC: prev, Epoch: types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, initialBlock)} + vs := types.ViewSpec{CommitQC: prev, Epochs: types.EpochDuo{Current: types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, initialBlock)}} fullProposal := utils.OrPanic1(types.NewProposal( leaderKey(committee, keys, vs.View()), vs, diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 7e6dff8f1c..48e2bafe0e 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -92,16 +92,16 @@ var logger = seilog.NewLogger("tendermint", "internal", "autobahn", "consensus") // Persisted state file prefix for consensus inner state. const innerFile = "inner" -// inner holds no registry: the epoch is provided from outside (newInner / +// inner holds no registry: the epoch window is provided from outside (newInner / // pushCommitQC on State), and epoch transitions are explicit. type inner struct { persistedInner - epoch *types.Epoch + epochs types.EpochDuo } // View returns the current view, embedding the epoch's index. func (i inner) View() types.View { - vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epoch: i.epoch} + vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epochs: i.epochs} return vs.View() } @@ -140,7 +140,15 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - return inner{persistedInner: persisted, epoch: viewEpoch}, nil + duo := types.EpochDuo{Current: viewEpoch} + if viewEpoch.EpochIndex() > 0 { + prev, err := registry.EpochAt(types.RoadIndex(viewEpoch.EpochIndex()-1) * epoch.EpochLength) + if err != nil { + return inner{}, fmt.Errorf("EpochAt(prev): %w", err) + } + duo.Prev = utils.Some(prev) + } + return inner{persistedInner: persisted, epochs: duo}, nil } func (s *State) pushCommitQC(qc *types.CommitQC) error { @@ -148,14 +156,16 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } - // Avail sequences CommitQCs before publishing LastCommitQC, so the tip - // must belong to the epoch consensus already holds. Mismatch is fatal — - // do not re-resolve via registry (that would paper over a sequencing bug). - if got, want := qc.Proposal().EpochIndex(), i.epoch.EpochIndex(); got != want { - return fmt.Errorf("CommitQC epoch %d != inner epoch %d (road %d)", + // Sequential guarantee: avail only advances LastCommitQC after accepting + // CommitQCs in road order, and consensus advances View from that tip. + // So a non-stale QC's EpochIndex must always match the current View's + // epoch (i.epochs.Current). Mismatch is a sequencing bug — hard-error; do not + // re-resolve via registry. + if got, want := qc.Proposal().EpochIndex(), i.epochs.Current.EpochIndex(); got != want { + return fmt.Errorf("CommitQC epoch %d != View epoch %d (road %d)", got, want, qc.Proposal().Index()) } - if err := qc.Verify(i.epoch); err != nil { + if err := qc.Verify(i.epochs.Current); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } for iSend := range s.inner.Lock() { @@ -163,24 +173,24 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } - // Epoch for the next view. Within an epoch this is i.epoch; on the + // Epoch for the next view. Within an epoch this is Current; on the // last CommitQC of the epoch it is an explicit transition, resolved // from the registry held by State (inner holds no registry). nextRoad := qc.Proposal().Index() + 1 - nextEp := i.epoch - if !i.epoch.RoadRange().Has(nextRoad) { + nextDuo := i.epochs + if !i.epochs.Current.RoadRange().Has(nextRoad) { // Invariant: N+1 is registered before this CommitQC (setup / // AdvanceIfNeeded). Hard-error if missing — do not WaitForDuo // like avail/data do. - var err error - nextEp, err = s.registry.EpochAt(nextRoad) + nextEp, err := s.registry.EpochAt(nextRoad) if err != nil { logger.Error("next epoch not in registry at CommitQC boundary", "road", nextRoad) return fmt.Errorf("EpochAt(%d): %w", nextRoad, err) } + nextDuo = types.EpochDuo{Prev: utils.Some(i.epochs.Current), Current: nextEp} } - iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, epoch: nextEp}) + iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, epochs: nextDuo}) } return nil } @@ -198,7 +208,7 @@ func (s *State) pushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { return nil } // Verify checks the invariant: TimeoutQC.View().Index == CommitQC.Index + 1 - if err := qc.Verify(i.epoch, i.CommitQC); err != nil { + if err := qc.Verify(i.epochs.Current, i.CommitQC); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } for isend := range s.inner.Lock() { @@ -206,8 +216,8 @@ func (s *State) pushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { if qc.View().Less(i.View()) { return nil } - // TimeoutQC advances view number; clear votes and prepareQC. Epoch unchanged. - isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, epoch: i.epoch}) + // TimeoutQC advances view number; clear votes and prepareQC. Epochs unchanged. + isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, epochs: i.epochs}) } return nil } @@ -249,7 +259,7 @@ func (s *State) pushPrepareQC(ctx context.Context, qc *types.PrepareQC) error { if vs.View() != qc.Proposal().View() { return nil } - if err := qc.Verify(vs.Epoch); err != nil { + if err := qc.Verify(vs.Epoch()); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } // Update. diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index eb119b12e4..d05e81d161 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -22,7 +22,7 @@ func testCommitQC( laneQCs map[types.LaneID]*types.LaneQC, appQC utils.Option[*types.AppQC], ) *types.CommitQC { - vs := types.ViewSpec{CommitQC: prev, Epoch: types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, 0)} + vs := types.ViewSpec{CommitQC: prev, Epochs: types.EpochDuo{Current: types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, 0)}} leader := committee.Leader(vs.View()) var leaderKey types.SecretKey for _, k := range keys { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go index b203d29453..048b32ed51 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go @@ -18,8 +18,8 @@ func makeSequentialFullCommitQCs( qcs := make([]*types.FullCommitQC, n) prev := utils.None[*types.CommitQC]() for i := range n { - vs := types.ViewSpec{CommitQC: prev, Epoch: registry.LatestEpoch()} - committee := vs.Epoch.Committee() + vs := types.ViewSpec{CommitQC: prev, Epochs: types.EpochDuo{Current: registry.LatestEpoch()}} + committee := vs.Epoch().Committee() lane := committee.Lanes().At(rng.Intn(committee.Lanes().Len())) b := types.NewBlock(lane, types.LaneRangeOpt(prev, lane).Next(), types.GenBlockHeaderHash(rng), types.GenPayload(rng)) lv := types.NewLaneVote(b.Header()) @@ -28,7 +28,7 @@ func makeSequentialFullCommitQCs( lvotes = append(lvotes, types.Sign(k, lv)) } laneQCs := map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(lvotes)} - cqc := types.BuildCommitQC(vs.Epoch, keys, prev, laneQCs, utils.None[*types.AppQC]()) + cqc := types.BuildCommitQC(vs.Epoch(), keys, prev, laneQCs, utils.None[*types.AppQC]()) qcs[i] = types.NewFullCommitQC(cqc, []*types.BlockHeader{b.Header()}) prev = utils.Some(qcs[i].QC()) } diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 542ca7596f..e969dd7e44 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -98,7 +98,7 @@ func (p *persistedInner) validate(commitEp, viewEp *types.Epoch) error { } } - vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epoch: viewEp} + vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epochs: types.EpochDuo{Current: viewEp}} currentView := vs.View() committee := viewEp.Committee() diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index ee6773e4da..3985f1d0e7 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -127,7 +127,7 @@ func newState( prepareVotes: utils.NewMutex(newPrepareVotes()), commitVotes: utils.NewMutex(newCommitVotes()), - myView: utils.NewAtomicSend(types.ViewSpec{CommitQC: initialInner.CommitQC, TimeoutQC: initialInner.TimeoutQC, Epoch: initialInner.epoch}), + myView: utils.NewAtomicSend(types.ViewSpec{CommitQC: initialInner.CommitQC, TimeoutQC: initialInner.TimeoutQC, Epochs: initialInner.epochs}), myProposal: utils.NewAtomicSend(utils.None[*types.FullProposal]()), myPrepareVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqPrepareVote]()), myCommitVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqCommitVote]()), @@ -177,12 +177,12 @@ func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { // error (avoid wrong-committee verify / peer teardown). No redelivery — // lagging peers recover via view timeout. i := s.innerRecv.Load() - if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epoch.EpochIndex() { + if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epochs.Current.EpochIndex() { logger.Debug("dropping prepare vote for non-current epoch", - "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epoch.EpochIndex())) + "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epochs.Current.EpochIndex())) return nil } - committee := i.epoch.Committee() + committee := i.epochs.Current.Committee() if err := vote.VerifySig(committee); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } @@ -196,12 +196,12 @@ func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { // Same Current-epoch contract as PushPrepareVote. i := s.innerRecv.Load() - if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epoch.EpochIndex() { + if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epochs.Current.EpochIndex() { logger.Debug("dropping commit vote for non-current epoch", - "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epoch.EpochIndex())) + "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epochs.Current.EpochIndex())) return nil } - committee := i.epoch.Committee() + committee := i.epochs.Current.Committee() if err := vote.VerifySig(committee); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } @@ -215,12 +215,12 @@ func (s *State) PushCommitVote(vote *types.Signed[*types.CommitVote]) error { func (s *State) PushTimeoutVote(vote *types.FullTimeoutVote) error { // Same Current-epoch contract as PushPrepareVote. i := s.innerRecv.Load() - if voteEp := vote.View().EpochIndex; voteEp != i.epoch.EpochIndex() { + if voteEp := vote.View().EpochIndex; voteEp != i.epochs.Current.EpochIndex() { logger.Debug("dropping timeout vote for non-current epoch", - "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epoch.EpochIndex())) + "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epochs.Current.EpochIndex())) return nil } - ep := i.epoch + ep := i.epochs.Current if err := vote.Verify(ep); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } @@ -237,7 +237,7 @@ func (s *State) Avail() *avail.State { return s.avail } // Constructs new proposals. func (s *State) runPropose(ctx context.Context) error { return s.myView.Iter(ctx, func(ctx context.Context, vs types.ViewSpec) error { - if vs.Epoch.Committee().Leader(vs.View()) != s.cfg.Key.Public() { + if vs.Epoch().Committee().Leader(vs.View()) != s.cfg.Key.Public() { return nil // not the leader. } // Try repropose. @@ -252,7 +252,7 @@ func (s *State) runPropose(ctx context.Context) error { } // The avail window may have advanced past the epoch we intend to // propose in; skip and let the next view catch up. - if ep.EpochIndex() != vs.Epoch.EpochIndex() { + if ep.EpochIndex() != vs.Epoch().EpochIndex() { return nil } // Construct a full proposal. @@ -285,7 +285,7 @@ func updateOutput[T types.ConsensusReq](w *utils.AtomicSend[utils.Option[T]], v // timers, neither of which constitutes a vote. func (s *State) runOutputs(ctx context.Context) error { return s.innerRecv.Iter(ctx, func(ctx context.Context, i inner) error { - vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epoch: i.epoch} + vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epochs: i.epochs} old := s.myView.Load() if old.View().Less(vs.View()) { s.myView.Store(vs) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index b6faeaf6dd..80aa652cd8 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -172,7 +172,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { malBlocks = append(malBlocks, b) } } - viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: registry.LatestEpoch()} + viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epochs: types.EpochDuo{Current: registry.LatestEpoch()}} leader := committee.Leader(viewSpec.View()) var leaderKey types.SecretKey for _, k := range keys { diff --git a/sei-tendermint/internal/autobahn/data/testonly.go b/sei-tendermint/internal/autobahn/data/testonly.go index 1e4e1e0a0c..99fc827128 100644 --- a/sei-tendermint/internal/autobahn/data/testonly.go +++ b/sei-tendermint/internal/autobahn/data/testonly.go @@ -60,7 +60,7 @@ func TestCommitQC( } var appQC utils.Option[*types.AppQC] if cqc, ok := prev.Get(); ok { - vs := types.ViewSpec{CommitQC: prev, Epoch: ep} + vs := types.ViewSpec{CommitQC: prev, Epochs: types.EpochDuo{Current: ep}} p := types.NewAppProposal(cqc.GlobalRange().Next-1, vs.View().Index, types.GenAppHash(rng), ep.EpochIndex()) appQC = utils.Some(TestAppQC(keys, p)) } From c82809fa331427d850e139505d6aabd26a2cc151 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 16 Jul 2026 21:01:03 -0700 Subject: [PATCH 57/98] fix(blocksim): update ViewSpec for EpochDuo field rename Co-authored-by: Cursor --- sei-db/ledger_db/block/blocksim/block_generator.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-db/ledger_db/block/blocksim/block_generator.go b/sei-db/ledger_db/block/blocksim/block_generator.go index 0b13f9ad0e..691d20acef 100644 --- a/sei-db/ledger_db/block/blocksim/block_generator.go +++ b/sei-db/ledger_db/block/blocksim/block_generator.go @@ -168,11 +168,11 @@ func (g *BlockGenerator) buildFullCommitQC() (*types.FullCommitQC, []*types.Bloc } } - viewSpec := types.ViewSpec{CommitQC: prev, Epoch: types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0)} + viewSpec := types.ViewSpec{CommitQC: prev, Epochs: types.EpochDuo{Current: types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0)}} leader := committee.Leader(viewSpec.View()) appQC := func() utils.Option[*types.AppQC] { if n := viewSpec.NextGlobalBlock(); n > 0 { - p := types.NewAppProposal(n-1, viewSpec.View().Index, types.AppHash(g.rand.Bytes(hashSizeBytes)), viewSpec.Epoch.EpochIndex()) + p := types.NewAppProposal(n-1, viewSpec.View().Index, types.AppHash(g.rand.Bytes(hashSizeBytes)), viewSpec.Epoch().EpochIndex()) return utils.Some(g.fakeAppQC(p)) } return utils.None[*types.AppQC]() From 0299d048848a07e79e0ac981102d1d9e33eee9d1 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 08:55:11 -0700 Subject: [PATCH 58/98] refactor(autobahn): SetupInitialDuo seeds from the last known CommitQC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeding is now stated in terms of the last CommitQC's epoch N: register {N-1, N, N+1} (the window the live registry holds while N runs), plus N+2 only when that QC closes epoch N — restoring what AdvanceIfNeeded had seeded before the restart. Fresh start (no QC) seeds {0, 1}. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner_test.go | 2 +- .../internal/autobahn/avail/state.go | 8 +++-- .../internal/autobahn/avail/state_test.go | 15 +++++---- .../internal/autobahn/consensus/inner.go | 8 +++-- .../internal/autobahn/data/state.go | 12 +++---- .../internal/autobahn/epoch/registry.go | 33 +++++++++++-------- .../internal/autobahn/epoch/registry_test.go | 29 ++++++++++++---- .../p2p/giga_router_validator_test.go | 2 +- 8 files changed, 70 insertions(+), 39 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 16f7aa54f6..4b5fe1ca95 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -117,7 +117,7 @@ func TestNewInnerLoadedNoAnchor(t *testing.T) { func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) - // SetupInitialDuo(0) already seeds epoch 1. + // NewRegistry already seeds epoch 1. duo := utils.OrPanic1(registry.DuoAt(epoch.EpochLength)) require.Equal(t, types.EpochIndex(1), duo.Current.EpochIndex()) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 7293fc0102..cb937aa761 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -158,12 +158,16 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin } // Operating duo is the CommitQC tipcut (not the prune-anchor road). - // Tip may lead data.SetupInitialDuo; seed around it before DuoAt. + // Avail's last QC may lead data's; seed from it before DuoAt. commitTip := types.RoadIndex(0) + lastQC := utils.None[types.RoadIndex]() if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() + if commitTip > 0 { + lastQC = utils.Some(commitTip - 1) + } } - data.Registry().SetupInitialDuo(commitTip) + data.Registry().SetupInitialDuo(lastQC) startDuo, err := data.Registry().DuoAt(commitTip) if err != nil { return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index a1ea2b8496..9312fc4019 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -879,7 +879,7 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) // Center the operating window on epoch 2 so road 0 is outside Prev|Current. - registry.SetupInitialDuo(2 * epoch.EpochLength) + registry.SetupInitialDuo(utils.Some(2 * epoch.EpochLength)) farDuo := utils.OrPanic1(registry.DuoAt(2 * epoch.EpochLength)) for inner := range state.inner.Lock() { inner.epochDuo.Store(farDuo) @@ -892,7 +892,7 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) - registry.SetupInitialDuo(2 * epoch.EpochLength) // {1,2,3}; genesis already has 0 + registry.SetupInitialDuo(utils.Some(2 * epoch.EpochLength)) // {1,2,3}; genesis already has 0 future := utils.OrPanic1(registry.EpochAt(2 * epoch.EpochLength)) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, @@ -926,7 +926,7 @@ func TestWaitEpochForRoadFutureBlocks(t *testing.T) { func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) - registry.SetupInitialDuo(2 * epoch.EpochLength) + registry.SetupInitialDuo(utils.Some(2 * epoch.EpochLength)) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err := NewState(keys[0], ds, utils.None[string]()) @@ -986,8 +986,8 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 3, 0) - // GenRegistryAt(0) / SetupInitialDuo(0) → epochs {0,1}; epoch 2 is unseeded - // until the last road of epoch 0 is executed (AdvanceIfNeeded) or a later SetupInitialDuo. + // Initial seeding registers epochs {0,1}; epoch 2 is unseeded until the + // last road of epoch 0 is executed (AdvanceIfNeeded) or a later SetupInitialDuo. ep1, err := registry.DuoAt(epoch.EpochLength) require.NoError(t, err) tip2 := ep1.Current.RoadRange().Next // first road of epoch 2 @@ -995,8 +995,9 @@ func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { _, err = registry.DuoAt(tip2) require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") - // Same seeding NewState does before DuoAt(commit tip). - registry.SetupInitialDuo(tip2) + // Same seeding NewState does before DuoAt(commit tip): last QC = tip-1, + // the boundary road closing epoch 1. + registry.SetupInitialDuo(utils.Some(tip2 - 1)) tipDuo2, err := registry.DuoAt(tip2) require.NoError(t, err) require.Equal(t, types.EpochIndex(2), tipDuo2.Current.EpochIndex()) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 48e2bafe0e..1e4aeba427 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -121,8 +121,12 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( // View epoch = tipcut road; CommitQC may still be the prior epoch's last road. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) - // Seed around consensus tip (may lead data WAL tip). TODO: verified snapshot. - registry.SetupInitialDuo(nextViewRoad) + // Seed from consensus's last CommitQC (may lead data's). TODO: verified snapshot. + lastQC := utils.None[types.RoadIndex]() + if cqc, ok := persisted.CommitQC.Get(); ok { + lastQC = utils.Some(cqc.Proposal().Index()) + } + registry.SetupInitialDuo(lastQC) viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 8df454dc2f..fba0e2fb95 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -301,15 +301,15 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if dataFirst > cfg.Registry.FirstBlock() { inner.skipTo(dataFirst) } - // Seed {N-1, N, N+1} around the WAL tip for QC verify / DuoAt. - // Invariant: CommitQC WAL span behind that tip is within this window - // (pruned vs latest AppQC today). TODO: verified snapshot / state-sync. + // Seed epochs from the last loaded CommitQC for QC verify / DuoAt. + // Invariant: the CommitQC WAL span behind that QC is within the seeded + // window (pruned vs latest AppQC today). TODO: verified snapshot / state-sync. loadedQCs := dataWAL.CommitQCs.ConsumeLoaded() - setupRoad := types.RoadIndex(0) + lastQC := utils.None[types.RoadIndex]() if n := len(loadedQCs); n > 0 { - setupRoad = loadedQCs[n-1].QC().Proposal().Index() + 1 + lastQC = utils.Some(loadedQCs[n-1].QC().Proposal().Index()) } - cfg.Registry.SetupInitialDuo(setupRoad) + cfg.Registry.SetupInitialDuo(lastQC) // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. for _, qc := range loadedQCs { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 67e800236b..125c315c48 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -27,8 +27,8 @@ type Registry struct { } // NewRegistry creates a Registry with the genesis committee. -// SetupInitialDuo(0) seeds epochs {0,1}; epoch 2 is registered when the last -// road of epoch 0 is executed (AdvanceIfNeeded). +// Initial seeding registers epochs {0,1} (both defined by genesis); epoch 2 is +// registered when the last road of epoch 0 is executed (AdvanceIfNeeded). func NewRegistry( committee *types.Committee, firstBlock types.GlobalBlockNumber, @@ -44,22 +44,29 @@ func NewRegistry( } // TODO: in the future this information will be read from disk and verified // (snapshots / state sync); until then seed a genesis placeholder. - r.SetupInitialDuo(0) + r.SetupInitialDuo(utils.None[types.RoadIndex]()) return r, nil } -// SetupInitialDuo registers placeholder epochs {N-1..N+1} around roadIndex -// (clamped at 0) with the genesis committee. Idempotent for existing entries. -// N+1 is included so tipcut into the next epoch works before the last road of -// N has been executed; AdvanceIfNeeded then seeds N+2 on that last road. +// SetupInitialDuo seeds placeholder epochs from the last known CommitQC road. +// For a CommitQC in epoch N it registers {N-1 (clamped), N, N+1} — the window +// the live registry holds while epoch N runs (N+1 has existed since the end of +// epoch N-1). If that CommitQC is the last road of epoch N it also registers +// N+2, restoring what AdvanceIfNeeded seeded before the restart. None (fresh +// start) seeds {0, 1}. Idempotent for existing entries. // TODO: replace with verified snapshot / state-sync epoch info. -func (r *Registry) SetupInitialDuo(roadIndex types.RoadIndex) { - n := types.EpochIndex(roadIndex / EpochLength) - first := types.EpochIndex(0) - if n >= 1 { - first = n - 1 +func (r *Registry) SetupInitialDuo(lastCommitQC utils.Option[types.RoadIndex]) { + first, last := types.EpochIndex(0), types.EpochIndex(1) + if road, ok := lastCommitQC.Get(); ok { + n := types.EpochIndex(road / EpochLength) + if n >= 1 { + first = n - 1 + } + last = n + 1 + if road+1 == types.RoadIndex(n+1)*EpochLength { + last = n + 2 // QC closes epoch N. + } } - last := n + 1 for s := range r.state.Lock() { for idx := first; idx <= last; idx++ { if _, ok := s.m[idx]; ok { diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index e961b0f18d..5b54df6d48 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -80,10 +80,10 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { } } -func TestSetupInitialDuo_WindowAroundTip(t *testing.T) { +func TestSetupInitialDuo_MidEpochQC(t *testing.T) { r, _ := makeRegistry(t) - // N=5 → {4,5,6}. Epoch 0 already present from NewRegistry. - r.SetupInitialDuo(5 * EpochLength) + // QC mid-epoch 5 → {4,5,6}. Epoch 0 already present from NewRegistry. + r.SetupInitialDuo(utils.Some(5 * EpochLength)) for _, idx := range []types.EpochIndex{4, 5, 6} { ep, err := r.EpochAt(types.RoadIndex(idx) * EpochLength) if err != nil { @@ -94,10 +94,25 @@ func TestSetupInitialDuo_WindowAroundTip(t *testing.T) { } } if _, err := r.EpochAt(3 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 3) should not be present after SetupInitialDuo(5*EpochLength)") + t.Fatal("EpochAt(epoch 3) should not be present after mid-epoch-5 seeding") } if _, err := r.EpochAt(7 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present after SetupInitialDuo(5*EpochLength)") + t.Fatal("EpochAt(epoch 7) should not be present after mid-epoch-5 seeding") + } +} + +func TestSetupInitialDuo_BoundaryQCSeedsNextNext(t *testing.T) { + r, _ := makeRegistry(t) + // QC on the last road of epoch 5 → {4,5,6,7}, matching what + // AdvanceIfNeeded had seeded before the restart. + r.SetupInitialDuo(utils.Some(6*EpochLength - 1)) + for _, idx := range []types.EpochIndex{4, 5, 6, 7} { + if _, err := r.EpochAt(types.RoadIndex(idx) * EpochLength); err != nil { + t.Fatalf("EpochAt(epoch %d) after boundary seeding: %v", idx, err) + } + } + if _, err := r.EpochAt(8 * EpochLength); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present after boundary-of-5 seeding") } } @@ -117,7 +132,7 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { func TestDuoAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) - r.SetupInitialDuo(2 * EpochLength) + r.SetupInitialDuo(utils.Some(2 * EpochLength)) duo, err := r.DuoAt(2 * EpochLength) if err != nil { t.Fatalf("DuoAt(epoch 2) error: %v", err) @@ -151,7 +166,7 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { func TestWaitForDuo_FastPathAndWait(t *testing.T) { r, _ := makeRegistry(t) - // NewRegistry SetupInitialDuo(0) → {0,1}; DuoAt(0) is immediate. + // NewRegistry seeds {0,1}; DuoAt(0) is immediate. duo, err := r.WaitForDuo(t.Context(), 0) require.NoError(t, err) require.Equal(t, types.EpochIndex(0), duo.Current.EpochIndex()) diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index d78be5e365..8f42500b84 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -193,7 +193,7 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { _, err := giga0.data.Registry().EpochAt(2 * epoch.EpochLength) require.Error(t, err, "epoch 2 should not be seeded before last road of epoch 0") _, err = giga0.data.Registry().EpochAt(epoch.EpochLength) - require.NoError(t, err, "SetupInitialDuo(0) should have seeded epoch 1") + require.NoError(t, err, "initial seeding should have registered epoch 1") return nil }) require.NoError(t, err) From 1c71d0d6b7a8ffb99eb992e832710da66fc1da39 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 09:28:39 -0700 Subject: [PATCH 59/98] refactor(autobahn): narrow SetupInitialDuo restart seeding to {N-1,N} Mid-epoch restart only restores the current epoch window; boundary CommitQCs additionally register N+1. Audit and fix epoch-window tests with a registerDuoAtEpoch helper. Co-authored-by: Cursor --- .../internal/autobahn/avail/state_test.go | 86 +++++++++---------- .../internal/autobahn/data/state_test.go | 4 +- .../internal/autobahn/epoch/registry.go | 12 ++- .../internal/autobahn/epoch/registry_test.go | 29 +++---- 4 files changed, 60 insertions(+), 71 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 9312fc4019..5b0494670f 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -24,6 +24,17 @@ var ( noCommitQCCB = utils.None[func(*types.CommitQC)]() ) +// registerDuoAtEpoch seeds the registry (via the end of epoch n-1) and +// installs Prev=n-1|Current=n as the state's operating window. +func registerDuoAtEpoch(s *State, n types.EpochIndex) { + r := s.data.Registry() + r.SetupInitialDuo(utils.Some(types.RoadIndex(n)*epoch.EpochLength - 1)) + duo := utils.OrPanic1(r.DuoAt(types.RoadIndex(n) * epoch.EpochLength)) + for inner := range s.inner.Lock() { + inner.epochDuo.Store(duo) + } +} + type byLane[T any] map[types.LaneID][]T func makeAppVotes(keys []types.SecretKey, proposal *types.AppProposal) []*types.Signed[*types.AppVote] { @@ -878,12 +889,8 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) - // Center the operating window on epoch 2 so road 0 is outside Prev|Current. - registry.SetupInitialDuo(utils.Some(2 * epoch.EpochLength)) - farDuo := utils.OrPanic1(registry.DuoAt(2 * epoch.EpochLength)) - for inner := range state.inner.Lock() { - inner.epochDuo.Store(farDuo) - } + // Move the operating window to Prev=1|Current=2 so road 0 is out of window. + registerDuoAtEpoch(state, 2) require.NoError(t, state.PushAppQC(t.Context(), appQC, qc0)) require.False(t, state.LastAppQC().IsPresent()) @@ -891,17 +898,17 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) - registry.SetupInitialDuo(utils.Some(2 * epoch.EpochLength)) // {1,2,3}; genesis already has 0 - future := utils.OrPanic1(registry.EpochAt(2 * epoch.EpochLength)) - + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; tip starts at road 0 ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) + // AppVote for epoch 1's first road: CommitQC tip is still 0, so PushAppVote + // blocks in waitForCommitQC (not on the epoch window). + ep1 := utils.OrPanic1(registry.EpochAt(epoch.EpochLength)) proposal := types.NewAppProposal( - future.FirstBlock(), future.RoadRange().First, types.GenAppHash(rng), future.EpochIndex()) + ep1.FirstBlock(), ep1.RoadRange().First, types.GenAppHash(rng), ep1.EpochIndex()) vote := types.Sign(keys[0], types.NewAppVote(proposal)) ctx, cancel := context.WithCancel(t.Context()) cancel() @@ -926,78 +933,63 @@ func TestWaitEpochForRoadFutureBlocks(t *testing.T) { func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) - registry.SetupInitialDuo(utils.Some(2 * epoch.EpochLength)) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - farDuo := utils.OrPanic1(registry.DuoAt(2 * epoch.EpochLength)) - for inner := range state.inner.Lock() { - inner.epochDuo.Store(farDuo) - } + registerDuoAtEpoch(state, 2) ep, err := state.waitEpochForRoad(t.Context(), 0) require.NoError(t, err) require.False(t, ep.IsPresent()) } -// TestPushAppQCPreviousEpoch verifies that an AppQC whose road index falls in -// epoch N-1 is accepted when the registry is seeded at epoch N. This exercises -// the path where a late AppQC arrives after an epoch boundary has been crossed. +// TestPushAppQCPreviousEpoch verifies that a late AppQC whose road falls in +// epoch N-1 is accepted after Current has advanced to epoch N. Its committee +// is resolved from Prev (N-1), not Current (N). func TestPushAppQCPreviousEpoch(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 3, 1) + registry, keys := epoch.GenRegistryAt(rng, 3, 0) // {0,1} + epochN1 := utils.OrPanic1(registry.EpochAt(0)) // epoch 0 (N-1) - epochN1, err := registry.EpochAt(0) // epoch 0 (N-1) - require.NoError(t, err) - epochN, err := registry.EpochAt(epoch.EpochLength) // epoch 1 (N) - require.NoError(t, err) - - // Build a CommitQC at the last road of epoch N-1. + // CommitQC + matching AppQC in epoch N-1 (road 0). lane := keys[0].Public() block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) laneQCs := map[types.LaneID]*types.LaneQC{ lane: types.NewLaneQC(makeLaneVotes(keys, block.Header())), } commitQC := makeCommitQC(epochN1, keys, utils.None[*types.CommitQC](), laneQCs, utils.None[*types.AppQC]()) - - // Build an AppQC referencing that CommitQC — it carries epoch N-1's index. gr := commitQC.GlobalRange() appProposal := types.NewAppProposal(gr.First, commitQC.Index(), types.GenAppHash(rng), epochN1.EpochIndex()) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - // Build a CommitQC at epoch N so the state is "at epoch N". - _ = epochN - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - // Push the epoch-N CommitQC first so the state has it, then push the late AppQC. - require.NoError(t, state.PushCommitQC(t.Context(), commitQC)) + // Force the operating window to Prev=0|Current=1 (as after a boundary CommitQC). + registerDuoAtEpoch(state, 1) + require.NoError(t, state.PushAppQC(t.Context(), appQC, commitQC), - "AppQC from epoch N-1 should be accepted when registry has epoch N-1 registered") + "late AppQC from epoch N-1 should be accepted after Current advanced to N") + require.True(t, state.LastAppQC().IsPresent()) } -// TestRestartDuoFromCommitTipNeedsSetup covers NewState seeding: DuoAt(tip) -// for tip past the seeded window needs SetupInitialDuo. +// TestRestartDuoFromCommitTipNeedsSetup covers restart seeding: DuoAt at the +// first road of epoch 2 fails until SetupInitialDuo sees a CommitQC that +// closed epoch 1 (registers epoch 2). Also checks newInner still requires a +// prune anchor when Current > 0. func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistryAt(rng, 3, 0) - // Initial seeding registers epochs {0,1}; epoch 2 is unseeded until the - // last road of epoch 0 is executed (AdvanceIfNeeded) or a later SetupInitialDuo. - ep1, err := registry.DuoAt(epoch.EpochLength) - require.NoError(t, err) - tip2 := ep1.Current.RoadRange().Next // first road of epoch 2 - require.Equal(t, types.RoadIndex(2*epoch.EpochLength), tip2) - _, err = registry.DuoAt(tip2) + registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} + tip2 := types.RoadIndex(2 * epoch.EpochLength) + _, err := registry.DuoAt(tip2) require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") - // Same seeding NewState does before DuoAt(commit tip): last QC = tip-1, - // the boundary road closing epoch 1. - registry.SetupInitialDuo(utils.Some(tip2 - 1)) + // NewState(lastQC = end of epoch 1) would call the same SetupInitialDuo. + registry.SetupInitialDuo(utils.Some(types.RoadIndex(2*epoch.EpochLength - 1))) tipDuo2, err := registry.DuoAt(tip2) require.NoError(t, err) require.Equal(t, types.EpochIndex(2), tipDuo2.Current.EpochIndex()) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 80aa652cd8..68f6cf666b 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -1140,8 +1140,8 @@ func TestPushBlockWaitsForQC(t *testing.T) { func TestPushQC_AdvancesEpochDuoAtBoundary(t *testing.T) { synctest.Test(t, func(t *testing.T) { rng := utils.TestRng() - // startEpoch=1: seeds epochs 0, 1, 2 so DuoAt works after advancing past epoch 0. - registry, keys := epoch.GenRegistryAt(rng, 3, 1) + // {0,1}: boundary CommitQC of epoch 0 WaitForDuo's into epoch 1. + registry, keys := epoch.GenRegistryAt(rng, 3, 0) state := utils.OrPanic1(NewState(&Config{Registry: registry}, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 125c315c48..b0a2bf5676 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -49,11 +49,9 @@ func NewRegistry( } // SetupInitialDuo seeds placeholder epochs from the last known CommitQC road. -// For a CommitQC in epoch N it registers {N-1 (clamped), N, N+1} — the window -// the live registry holds while epoch N runs (N+1 has existed since the end of -// epoch N-1). If that CommitQC is the last road of epoch N it also registers -// N+2, restoring what AdvanceIfNeeded seeded before the restart. None (fresh -// start) seeds {0, 1}. Idempotent for existing entries. +// For a CommitQC mid-epoch N it registers {N-1 (clamped), N}. If that CommitQC +// is the last road of epoch N it also registers N+1 (the next epoch). None +// (fresh start) seeds {0, 1}. Idempotent for existing entries. // TODO: replace with verified snapshot / state-sync epoch info. func (r *Registry) SetupInitialDuo(lastCommitQC utils.Option[types.RoadIndex]) { first, last := types.EpochIndex(0), types.EpochIndex(1) @@ -62,9 +60,9 @@ func (r *Registry) SetupInitialDuo(lastCommitQC utils.Option[types.RoadIndex]) { if n >= 1 { first = n - 1 } - last = n + 1 + last = n if road+1 == types.RoadIndex(n+1)*EpochLength { - last = n + 2 // QC closes epoch N. + last = n + 1 // QC closes epoch N → seed next. } } for s := range r.state.Lock() { diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 5b54df6d48..776c2383f9 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -80,14 +80,14 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { } } -func TestSetupInitialDuo_MidEpochQC(t *testing.T) { +func TestSetupInitialDuo_MidEpoch(t *testing.T) { r, _ := makeRegistry(t) - // QC mid-epoch 5 → {4,5,6}. Epoch 0 already present from NewRegistry. - r.SetupInitialDuo(utils.Some(5 * EpochLength)) - for _, idx := range []types.EpochIndex{4, 5, 6} { + // Mid-epoch 5 → {4,5}. Epoch 0 already present from NewRegistry. + r.SetupInitialDuo(utils.Some(5*EpochLength + EpochLength/2)) + for _, idx := range []types.EpochIndex{4, 5} { ep, err := r.EpochAt(types.RoadIndex(idx) * EpochLength) if err != nil { - t.Fatalf("EpochAt(epoch %d) after SetupInitialDuo: %v", idx, err) + t.Fatalf("EpochAt(epoch %d) after mid-epoch seeding: %v", idx, err) } if ep.EpochIndex() != idx { t.Fatalf("EpochAt(epoch %d).EpochIndex() = %d, want %d", idx, ep.EpochIndex(), idx) @@ -96,23 +96,22 @@ func TestSetupInitialDuo_MidEpochQC(t *testing.T) { if _, err := r.EpochAt(3 * EpochLength); err == nil { t.Fatal("EpochAt(epoch 3) should not be present after mid-epoch-5 seeding") } - if _, err := r.EpochAt(7 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present after mid-epoch-5 seeding") + if _, err := r.EpochAt(6 * EpochLength); err == nil { + t.Fatal("EpochAt(epoch 6) should not be present after mid-epoch-5 seeding") } } -func TestSetupInitialDuo_BoundaryQCSeedsNextNext(t *testing.T) { +func TestSetupInitialDuo_EndOfEpoch(t *testing.T) { r, _ := makeRegistry(t) - // QC on the last road of epoch 5 → {4,5,6,7}, matching what - // AdvanceIfNeeded had seeded before the restart. + // Last road of epoch 5 → {4,5,6}. r.SetupInitialDuo(utils.Some(6*EpochLength - 1)) - for _, idx := range []types.EpochIndex{4, 5, 6, 7} { + for _, idx := range []types.EpochIndex{4, 5, 6} { if _, err := r.EpochAt(types.RoadIndex(idx) * EpochLength); err != nil { - t.Fatalf("EpochAt(epoch %d) after boundary seeding: %v", idx, err) + t.Fatalf("EpochAt(epoch %d) after end-of-epoch seeding: %v", idx, err) } } - if _, err := r.EpochAt(8 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 8) should not be present after boundary-of-5 seeding") + if _, err := r.EpochAt(7 * EpochLength); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present after end-of-epoch-5 seeding") } } @@ -132,7 +131,7 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { func TestDuoAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) - r.SetupInitialDuo(utils.Some(2 * EpochLength)) + r.SetupInitialDuo(utils.Some(2*EpochLength + EpochLength/2)) duo, err := r.DuoAt(2 * EpochLength) if err != nil { t.Fatalf("DuoAt(epoch 2) error: %v", err) From ade98c6f1e2c7ac84c1cbe4eef7eff349a72a324 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 10:46:59 -0700 Subject: [PATCH 60/98] refactor(autobahn): seed restart epochs from data CommitQC + execution tip Own SetupInitialDuo in data.NewState only; execution may extend the high end to E+1 while CommitQC keeps {N-1,N}. Avail/consensus must fit that window. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner_test.go | 6 +- .../internal/autobahn/avail/state.go | 8 +- .../internal/autobahn/avail/state_test.go | 27 ++-- .../internal/autobahn/consensus/inner.go | 10 +- .../internal/autobahn/data/state.go | 32 ++++- .../internal/autobahn/data/state_test.go | 2 +- .../internal/autobahn/epoch/registry.go | 115 +++++++++++++---- .../internal/autobahn/epoch/registry_test.go | 119 ++++++++++++------ .../internal/autobahn/epoch/testonly.go | 4 +- .../internal/p2p/giga_router_common.go | 13 +- .../p2p/giga_router_validator_test.go | 4 +- 11 files changed, 236 insertions(+), 104 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 4b5fe1ca95..1ed4be1a89 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -118,7 +118,7 @@ func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() registry, _, _ := epoch.GenRegistry(rng, 4) // NewRegistry already seeds epoch 1. - duo := utils.OrPanic1(registry.DuoAt(epoch.EpochLength)) + duo := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(1))) require.Equal(t, types.EpochIndex(1), duo.Current.EpochIndex()) _, err := newInner(duo, utils.Some(&loadedAvailState{})) @@ -872,7 +872,7 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { // registry epochs share genesis FirstBlock, so inflate for this invariant). tipFirst := wantAppFirst + 1000 c := ep0.Committee() - tipCurrent := types.NewEpoch(1, types.RoadRange{First: epoch.EpochLength, Next: 2 * epoch.EpochLength}, ep0.FirstTimestamp(), c, tipFirst) + tipCurrent := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, ep0.FirstTimestamp(), c, tipFirst) tipDuo := types.EpochDuo{Prev: utils.Some(ep0), Current: tipCurrent} ap := types.NewAppProposal(wantAppFirst, qcs[1].Index(), types.GenAppHash(rng), 0) @@ -957,7 +957,7 @@ func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { require.Contains(t, i.lanes, epoch0Lane, "epoch0 lane missing before reweight") // duo1: Prev=epoch0, Current=epoch1 - duo1 := utils.OrPanic1(registry.DuoAt(epoch.EpochLength)) + duo1 := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(1))) i.advanceEpoch(duo1) // Epoch0 lane is now in Prev — must be retained for boundary QC collection. diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index cb937aa761..5d7b9276e5 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -158,16 +158,12 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin } // Operating duo is the CommitQC tipcut (not the prune-anchor road). - // Avail's last QC may lead data's; seed from it before DuoAt. + // Epoch seeding is owned by data.NewState (SetupInitialDuo); this tip + // must fall in that window (avail may not lead data across an unseeded epoch). commitTip := types.RoadIndex(0) - lastQC := utils.None[types.RoadIndex]() if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() - if commitTip > 0 { - lastQC = utils.Some(commitTip - 1) - } } - data.Registry().SetupInitialDuo(lastQC) startDuo, err := data.Registry().DuoAt(commitTip) if err != nil { return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 5b0494670f..a6114ea692 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -24,12 +24,12 @@ var ( noCommitQCCB = utils.None[func(*types.CommitQC)]() ) -// registerDuoAtEpoch seeds the registry (via the end of epoch n-1) and -// installs Prev=n-1|Current=n as the state's operating window. +// registerDuoAtEpoch seeds the registry (CommitQC tip in epoch n → {n-1, n}) +// and installs Prev=n-1|Current=n as the state's operating window. func registerDuoAtEpoch(s *State, n types.EpochIndex) { r := s.data.Registry() - r.SetupInitialDuo(utils.Some(types.RoadIndex(n)*epoch.EpochLength - 1)) - duo := utils.OrPanic1(r.DuoAt(types.RoadIndex(n) * epoch.EpochLength)) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(epoch.FirstRoad(n))) + duo := utils.OrPanic1(r.DuoAt(epoch.FirstRoad(n))) for inner := range s.inner.Lock() { inner.epochDuo.Store(duo) } @@ -906,7 +906,7 @@ func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { // AppVote for epoch 1's first road: CommitQC tip is still 0, so PushAppVote // blocks in waitForCommitQC (not on the epoch window). - ep1 := utils.OrPanic1(registry.EpochAt(epoch.EpochLength)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) proposal := types.NewAppProposal( ep1.FirstBlock(), ep1.RoadRange().First, types.GenAppHash(rng), ep1.EpochIndex()) vote := types.Sign(keys[0], types.NewAppVote(proposal)) @@ -926,7 +926,7 @@ func TestWaitEpochForRoadFutureBlocks(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) cancel() - _, err = state.waitEpochForRoad(ctx, 2*epoch.EpochLength) + _, err = state.waitEpochForRoad(ctx, epoch.FirstRoad(2)) require.ErrorIs(t, err, context.Canceled) } @@ -978,18 +978,21 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { } // TestRestartDuoFromCommitTipNeedsSetup covers restart seeding: DuoAt at the -// first road of epoch 2 fails until SetupInitialDuo sees a CommitQC that -// closed epoch 1 (registers epoch 2). Also checks newInner still requires a -// prune anchor when Current > 0. +// first road of epoch 2 needs epoch 2. CommitQC alone in epoch 1 is not +// enough; execution on the closing road of epoch 1 extends to epoch 2. +// Also checks newInner still requires a prune anchor when Current > 0. func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} - tip2 := types.RoadIndex(2 * epoch.EpochLength) + tip2 := epoch.FirstRoad(2) _, err := registry.DuoAt(tip2) require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") - // NewState(lastQC = end of epoch 1) would call the same SetupInitialDuo. - registry.SetupInitialDuo(utils.Some(types.RoadIndex(2*epoch.EpochLength - 1))) + // CommitQC closing epoch 1 opens {0,1}; execution on that road adds 2. + registry.SetupInitialDuo( + utils.Some(epoch.LastRoad(1)), + utils.Some(epoch.LastRoad(1)), + ) tipDuo2, err := registry.DuoAt(tip2) require.NoError(t, err) require.Equal(t, types.EpochIndex(2), tipDuo2.Current.EpochIndex()) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 1e4aeba427..3a73b51a7c 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -120,13 +120,9 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( } // View epoch = tipcut road; CommitQC may still be the prior epoch's last road. + // Epoch seeding is owned by data.NewState (SetupInitialDuo); consensus tip + // must fall in that window (may not lead data across an unseeded epoch). nextViewRoad := types.NextIndexOpt(persisted.CommitQC) - // Seed from consensus's last CommitQC (may lead data's). TODO: verified snapshot. - lastQC := utils.None[types.RoadIndex]() - if cqc, ok := persisted.CommitQC.Get(); ok { - lastQC = utils.Some(cqc.Proposal().Index()) - } - registry.SetupInitialDuo(lastQC) viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) @@ -146,7 +142,7 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( duo := types.EpochDuo{Current: viewEpoch} if viewEpoch.EpochIndex() > 0 { - prev, err := registry.EpochAt(types.RoadIndex(viewEpoch.EpochIndex()-1) * epoch.EpochLength) + prev, err := registry.EpochAt(epoch.FirstRoad(viewEpoch.EpochIndex() - 1)) if err != nil { return inner{}, fmt.Errorf("EpochAt(prev): %w", err) } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index fba0e2fb95..935cf051ad 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -28,6 +28,13 @@ type Config struct { Registry *epoch.Registry // PruneAfter is the duration after which the state prunes executed blocks. PruneAfter utils.Option[time.Duration] + // LastExecutedBlock is the last app-committed global height + // (app.LastBlockHeight). Used only to map → CommitQC road for + // SetupInitialDuo. 0 means fresh / unknown. + // + // TODO(autobahn): This is read from the Cosmos app state DB today. Autobahn + // should not depend on the app DB — move executed height into Giga storage. + LastExecutedBlock types.GlobalBlockNumber } // StateAPI is the interface of the State for consuming global blocks @@ -301,15 +308,18 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if dataFirst > cfg.Registry.FirstBlock() { inner.skipTo(dataFirst) } - // Seed epochs from the last loaded CommitQC for QC verify / DuoAt. - // Invariant: the CommitQC WAL span behind that QC is within the seeded - // window (pruned vs latest AppQC today). TODO: verified snapshot / state-sync. + // Seed epochs once here (avail/consensus do not call SetupInitialDuo). + // CommitQC tip opens Prev|Current; execution tip may extend the high end. + // + // TODO(autobahn): LastExecutedBlock comes from app.LastBlockHeight() (app + // state DB). Move execution tip into Giga storage; stop reading the app DB. loadedQCs := dataWAL.CommitQCs.ConsumeLoaded() lastQC := utils.None[types.RoadIndex]() if n := len(loadedQCs); n > 0 { lastQC = utils.Some(loadedQCs[n-1].QC().Proposal().Index()) } - cfg.Registry.SetupInitialDuo(lastQC) + lastExecuted := roadForGlobal(loadedQCs, cfg.LastExecutedBlock) + cfg.Registry.SetupInitialDuo(lastExecuted, lastQC) // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. for _, qc := range loadedQCs { @@ -376,6 +386,20 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { }, nil } +// roadForGlobal returns the CommitQC road covering global block n, if present +// in the loaded WAL QCs. None when n==0 or the QC was pruned / not loaded. +func roadForGlobal(qcs []*types.FullCommitQC, n types.GlobalBlockNumber) utils.Option[types.RoadIndex] { + if n == 0 { + return utils.None[types.RoadIndex]() + } + for i := len(qcs) - 1; i >= 0; i-- { + if qcs[i].QC().GlobalRange().Has(n) { + return utils.Some(qcs[i].QC().Proposal().Index()) + } + } + return utils.None[types.RoadIndex]() +} + // Registry returns the epoch registry. func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 68f6cf666b..c834463f89 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -1149,7 +1149,7 @@ func TestPushQC_AdvancesEpochDuoAtBoundary(t *testing.T) { require.Equal(t, types.EpochIndex(0), ep0.EpochIndex()) // Build a CommitQC at the last road of epoch 0 (no lane blocks needed). - view := types.View{EpochIndex: ep0.EpochIndex(), Index: epoch.EpochLength - 1} + view := types.View{EpochIndex: ep0.EpochIndex(), Index: epoch.LastRoad(0)} vote := types.NewCommitVote(types.ProposalAt(ep0, view)) vs := make([]*types.Signed[*types.CommitVote], len(keys)) for i, k := range keys { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index b0a2bf5676..8b1ae74bdf 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -3,15 +3,34 @@ package epoch import ( "context" "fmt" + "log/slog" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/seilog" ) +var logger = seilog.NewLogger("tendermint", "internal", "autobahn", "epoch") + // EpochLength is the number of road indices per epoch. const EpochLength types.RoadIndex = 108_000 +// IndexForRoad returns the epoch index containing road. +func IndexForRoad(road types.RoadIndex) types.EpochIndex { + return types.EpochIndex(road / EpochLength) +} + +// FirstRoad returns the first road index of epoch idx. +func FirstRoad(idx types.EpochIndex) types.RoadIndex { + return types.RoadIndex(idx) * EpochLength +} + +// LastRoad returns the last road index of epoch idx (half-open Next-1). +func LastRoad(idx types.EpochIndex) types.RoadIndex { + return FirstRoad(idx+1) - 1 +} + type registryState struct { m map[types.EpochIndex]*types.Epoch latest types.EpochIndex @@ -34,7 +53,7 @@ func NewRegistry( firstBlock types.GlobalBlockNumber, genesisTimestamp time.Time, ) (*Registry, error) { - ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: EpochLength}, genesisTimestamp, committee, firstBlock) + ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, genesisTimestamp, committee, firstBlock) r := &Registry{ state: utils.NewRWMutex(®istryState{ m: map[types.EpochIndex]*types.Epoch{0: ep}, @@ -44,29 +63,69 @@ func NewRegistry( } // TODO: in the future this information will be read from disk and verified // (snapshots / state sync); until then seed a genesis placeholder. - r.SetupInitialDuo(utils.None[types.RoadIndex]()) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadIndex]()) return r, nil } -// SetupInitialDuo seeds placeholder epochs from the last known CommitQC road. -// For a CommitQC mid-epoch N it registers {N-1 (clamped), N}. If that CommitQC -// is the last road of epoch N it also registers N+1 (the next epoch). None -// (fresh start) seeds {0, 1}. Idempotent for existing entries. -// TODO: replace with verified snapshot / state-sync epoch info. -func (r *Registry) SetupInitialDuo(lastCommitQC utils.Option[types.RoadIndex]) { - first, last := types.EpochIndex(0), types.EpochIndex(1) +// SetupInitialDuo seeds placeholder epochs on restart. Called only from +// data.NewState (avail/consensus rely on that window; their tips must not lead +// data across an unseeded epoch). +// +// lastCommitQC in epoch N registers {N-1 (clamped), N} — the Prev|Current window +// needed to DuoAt/verify that tip. lastExecutedRoad may extend the high end +// only (never shrink): a road in epoch E implies E+1 is present. If execution is +// past the CommitQC tip, that is a bug — warn and ignore it. +// +// None/None (fresh start) seeds {0, 1}. Idempotent for existing entries. +// +// TODO(autobahn): lastExecutedRoad is derived from app.LastBlockHeight() (Cosmos +// app state DB). Do not keep depending on the app DB for execution tip / epoch +// seeding — persist that in Giga storage alongside CommitQC/AppQC WALs. +func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[types.RoadIndex]) { + var windowFirst, windowLast types.EpochIndex + haveWindow := false + if road, ok := lastCommitQC.Get(); ok { - n := types.EpochIndex(road / EpochLength) - if n >= 1 { - first = n - 1 + tipEpoch := IndexForRoad(road) + windowFirst = 0 + if tipEpoch >= 1 { + windowFirst = tipEpoch - 1 } - last = n - if road+1 == types.RoadIndex(n+1)*EpochLength { - last = n + 1 // QC closes epoch N → seed next. + windowLast = tipEpoch + haveWindow = true + } + + if road, ok := lastExecutedRoad.Get(); ok { + if commitRoad, cok := lastCommitQC.Get(); cok && road > commitRoad { + logger.Warn("execution tip past CommitQC tip on restart; ignoring executed tip for epoch seeding", + slog.Uint64("executed_road", uint64(road)), + slog.Uint64("commit_qc_road", uint64(commitRoad))) + } else { + tipEpoch := IndexForRoad(road) + // End of tipEpoch-1 already ran → tipEpoch+1 is present. + high := tipEpoch + 1 + if !haveWindow { + // Execution-only: open Prev|Current around the executed tip, then + // extend forward. + windowFirst = 0 + if tipEpoch >= 1 { + windowFirst = tipEpoch - 1 + } + windowLast = tipEpoch + haveWindow = true + } + if high > windowLast { + windowLast = high + } } } + + if !haveWindow { + windowFirst, windowLast = 0, 1 // fresh start + } + for s := range r.state.Lock() { - for idx := first; idx <= last; idx++ { + for idx := windowFirst; idx <= windowLast; idx++ { if _, ok := s.m[idx]; ok { continue } @@ -88,7 +147,7 @@ func (r *Registry) FirstBlock() types.GlobalBlockNumber { // Returns an error if the epoch has not been registered via SetupInitialDuo or // AdvanceIfNeeded. func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { - epochIdx := types.EpochIndex(roadIndex / EpochLength) + epochIdx := IndexForRoad(roadIndex) for s := range r.state.RLock() { if ep, ok := s.m[epochIdx]; ok { return ep, nil @@ -107,8 +166,8 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type if !ok { return nil, fmt.Errorf("genesis epoch missing from registry") } - firstRoad := types.RoadIndex(uint64(epochIdx) * uint64(EpochLength)) - epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: firstRoad + EpochLength}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) + firstRoad := FirstRoad(epochIdx) + epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) s.m[epochIdx] = epoch // Wake WaitForDuo waiters. makeEpoch runs under the write lock, so this // Load/Store is serialized; highestEpoch only advances. @@ -119,15 +178,17 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type } // AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed. -// Earlier roads in the epoch are a no-op. Committee for N+2 is derived from -// finishing epoch N; WaitForDuo covers cases where setup has not yet filled N+1. +// Earlier roads in the epoch are a no-op. Restart SetupInitialDuo restores the +// N+1 that this would have seeded at the end of N-1. Committee for N+2 is +// currently the genesis committee. // TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { - next := types.RoadIndex(types.EpochIndex(roadIndex/EpochLength)+1) * EpochLength + tipEpoch := IndexForRoad(roadIndex) + next := FirstRoad(tipEpoch + 1) if roadIndex+1 != next { return // not the last road of its epoch } - nextNextIdx := types.EpochIndex(roadIndex/EpochLength) + 2 + nextNextIdx := tipEpoch + 2 // Fast path: epoch already seeded. for s := range r.state.RLock() { if _, ok := s.m[nextNextIdx]; ok { @@ -149,14 +210,14 @@ func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { // a missing epoch below the retain window should surface as ErrPruned so // callers can silently drop rather than Wait forever. func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { - centerIdx := types.EpochIndex(roadIndex / EpochLength) - current, err := r.EpochAt(types.RoadIndex(centerIdx) * EpochLength) + centerIdx := IndexForRoad(roadIndex) + current, err := r.EpochAt(FirstRoad(centerIdx)) if err != nil { return types.EpochDuo{}, fmt.Errorf("epoch %d (road %d) not in registry", centerIdx, roadIndex) } duo := types.EpochDuo{Current: current} if centerIdx > 0 { - if prev, err := r.EpochAt(types.RoadIndex(centerIdx-1) * EpochLength); err == nil { + if prev, err := r.EpochAt(FirstRoad(centerIdx - 1)); err == nil { duo.Prev = utils.Some(prev) } } @@ -170,7 +231,7 @@ func (r *Registry) WaitForDuo(ctx context.Context, roadIndex types.RoadIndex) (t if duo, err := r.DuoAt(roadIndex); err == nil { return duo, nil } - centerIdx := types.EpochIndex(roadIndex / EpochLength) + centerIdx := IndexForRoad(roadIndex) if _, err := r.highestEpoch.Subscribe().Wait(ctx, func(highest types.EpochIndex) bool { return highest >= centerIdx }); err != nil { diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 776c2383f9..fc604aed4c 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -21,6 +21,10 @@ func makeRegistry(t *testing.T) (*Registry, *types.Committee) { return r, committee } +func midRoad(idx types.EpochIndex) types.RoadIndex { + return FirstRoad(idx) + EpochLength/2 +} + func TestNewRegistry_GenesisEpochBoundedRange(t *testing.T) { r, _ := makeRegistry(t) ep, err := r.EpochAt(0) @@ -28,8 +32,8 @@ func TestNewRegistry_GenesisEpochBoundedRange(t *testing.T) { t.Fatalf("EpochAt(0): %v", err) } rng := ep.RoadRange() - if rng.First != 0 || rng.Next != EpochLength { - t.Fatalf("genesis RoadRange = {%d, %d}, want {0, %d}", rng.First, rng.Next, EpochLength) + if rng.First != 0 || rng.Next != FirstRoad(1) { + t.Fatalf("genesis RoadRange = {%d, %d}, want {0, %d}", rng.First, rng.Next, FirstRoad(1)) } } @@ -46,20 +50,20 @@ func TestEpochAt_GenesisEpoch(t *testing.T) { func TestEpochAt_WithinGenesisEpoch(t *testing.T) { r, _ := makeRegistry(t) - ep, err := r.EpochAt(EpochLength - 1) + ep, err := r.EpochAt(LastRoad(0)) if err != nil { - t.Fatalf("EpochAt(EpochLength-1) error: %v", err) + t.Fatalf("EpochAt(LastRoad(0)) error: %v", err) } if ep.EpochIndex() != 0 { - t.Fatalf("EpochAt(EpochLength-1).EpochIndex() = %d, want 0", ep.EpochIndex()) + t.Fatalf("EpochAt(LastRoad(0)).EpochIndex() = %d, want 0", ep.EpochIndex()) } } func TestEpochAt_ErrorIfNotRegistered(t *testing.T) { r, _ := makeRegistry(t) - _, err := r.EpochAt(2 * EpochLength) + _, err := r.EpochAt(FirstRoad(2)) if err == nil { - t.Fatal("EpochAt(2*EpochLength) expected error for unregistered epoch, got nil") + t.Fatal("EpochAt(FirstRoad(2)) expected error for unregistered epoch, got nil") } } @@ -67,51 +71,88 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { r, _ := makeRegistry(t) // NewRegistry already seeds {0,1}. Only the last road of epoch 0 seeds epoch 2. r.AdvanceIfNeeded(0) - if _, err := r.EpochAt(2 * EpochLength); err == nil { + if _, err := r.EpochAt(FirstRoad(2)); err == nil { t.Fatal("AdvanceIfNeeded(0) must not seed epoch 2") } - r.AdvanceIfNeeded(EpochLength - 1) - ep, err := r.EpochAt(2 * EpochLength) + r.AdvanceIfNeeded(LastRoad(0)) + ep, err := r.EpochAt(FirstRoad(2)) if err != nil { - t.Fatalf("EpochAt(2*EpochLength) after last road of epoch 0: %v", err) + t.Fatalf("EpochAt(FirstRoad(2)) after last road of epoch 0: %v", err) } if ep.EpochIndex() != 2 { - t.Fatalf("EpochAt(2*EpochLength).EpochIndex() = %d, want 2", ep.EpochIndex()) + t.Fatalf("EpochAt(FirstRoad(2)).EpochIndex() = %d, want 2", ep.EpochIndex()) } } -func TestSetupInitialDuo_MidEpoch(t *testing.T) { +func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { r, _ := makeRegistry(t) - // Mid-epoch 5 → {4,5}. Epoch 0 already present from NewRegistry. - r.SetupInitialDuo(utils.Some(5*EpochLength + EpochLength/2)) + // CommitQC mid-epoch 5 → {4,5} only; does not claim AdvanceIfNeeded. + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(midRoad(5))) for _, idx := range []types.EpochIndex{4, 5} { - ep, err := r.EpochAt(types.RoadIndex(idx) * EpochLength) - if err != nil { - t.Fatalf("EpochAt(epoch %d) after mid-epoch seeding: %v", idx, err) - } - if ep.EpochIndex() != idx { - t.Fatalf("EpochAt(epoch %d).EpochIndex() = %d, want %d", idx, ep.EpochIndex(), idx) + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after CommitQC seeding: %v", idx, err) } } - if _, err := r.EpochAt(3 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 3) should not be present after mid-epoch-5 seeding") + if _, err := r.EpochAt(FirstRoad(6)); err == nil { + t.Fatal("EpochAt(epoch 6) should not be present from CommitQC alone") } - if _, err := r.EpochAt(6 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 6) should not be present after mid-epoch-5 seeding") +} + +func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { + r, _ := makeRegistry(t) + // CommitQC in epoch 5 opens {4,5}; execution mid-5 adds 6. + r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(midRoad(5))) + for _, idx := range []types.EpochIndex{4, 5, 6} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after execution extend: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present after mid-epoch execution") } } -func TestSetupInitialDuo_EndOfEpoch(t *testing.T) { +func TestSetupInitialDuo_ExecutionClosingStillOnlyAddsNext(t *testing.T) { r, _ := makeRegistry(t) - // Last road of epoch 5 → {4,5,6}. - r.SetupInitialDuo(utils.Some(6*EpochLength - 1)) + // Closing road of epoch 5 is still "execution in 5" → high end 6, not 7. + // (tipEpoch+2 would mean executing past CommitQC; we do not handle that.) + r.SetupInitialDuo(utils.Some(LastRoad(5)), utils.Some(LastRoad(5))) for _, idx := range []types.EpochIndex{4, 5, 6} { - if _, err := r.EpochAt(types.RoadIndex(idx) * EpochLength); err != nil { - t.Fatalf("EpochAt(epoch %d) after end-of-epoch seeding: %v", idx, err) + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after closing execution: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present from closing-road execution") + } +} + +func TestSetupInitialDuo_ExecutionPastCommitQCIgnored(t *testing.T) { + r, _ := makeRegistry(t) + // CommitQC mid-3 → {2,3}; execution mid-5 is past CommitQC → warn, ignore. + r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(midRoad(3))) + for _, idx := range []types.EpochIndex{2, 3} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) from CommitQC: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(4)); err == nil { + t.Fatal("EpochAt(epoch 4) should not be present when execution past CommitQC is ignored") + } +} + +func TestSetupInitialDuo_ExecutionDoesNotShrinkCommitWindow(t *testing.T) { + r, _ := makeRegistry(t) + // CommitQC in epoch 5 → {4,5}; lagging execution in epoch 3 must not drop 4/5. + r.SetupInitialDuo(utils.Some(midRoad(3)), utils.Some(midRoad(5))) + for _, idx := range []types.EpochIndex{4, 5} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) must remain after lagging execution: %v", idx, err) } } - if _, err := r.EpochAt(7 * EpochLength); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present after end-of-epoch-5 seeding") + // Execution mid-3 extends high end to 4 (already present); not to 6. + if _, err := r.EpochAt(FirstRoad(6)); err == nil { + t.Fatal("EpochAt(epoch 6) should not be present when execution lags in epoch 3") } } @@ -131,8 +172,8 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { func TestDuoAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) - r.SetupInitialDuo(utils.Some(2*EpochLength + EpochLength/2)) - duo, err := r.DuoAt(2 * EpochLength) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(midRoad(2))) + duo, err := r.DuoAt(FirstRoad(2)) if err != nil { t.Fatalf("DuoAt(epoch 2) error: %v", err) } @@ -149,7 +190,7 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ types.GenSecretKey(utils.TestRng()).Public(): 1, })) - ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: EpochLength}, time.Time{}, committee, 0) + ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, time.Time{}, committee, 0) bare := &Registry{ state: utils.NewRWMutex(®istryState{ m: map[types.EpochIndex]*types.Epoch{0: ep}, @@ -157,9 +198,9 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { }), highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), } - _, err := bare.DuoAt(EpochLength) + _, err := bare.DuoAt(FirstRoad(1)) if err == nil { - t.Fatal("DuoAt(EpochLength) expected error when Current epoch not registered, got nil") + t.Fatal("DuoAt(FirstRoad(1)) expected error when Current epoch not registered, got nil") } } @@ -171,7 +212,7 @@ func TestWaitForDuo_FastPathAndWait(t *testing.T) { require.Equal(t, types.EpochIndex(0), duo.Current.EpochIndex()) // Tipcut into epoch 2 needs epoch 2 registered (seeded by executing epoch 0). - tip := 2 * EpochLength + tip := FirstRoad(2) _, err = r.DuoAt(tip) require.Error(t, err) @@ -184,7 +225,7 @@ func TestWaitForDuo_FastPathAndWait(t *testing.T) { duo, err := r.WaitForDuo(t.Context(), tip) done <- result{duo, err} }() - r.AdvanceIfNeeded(EpochLength - 1) // last road of epoch 0 seeds epoch 2 + r.AdvanceIfNeeded(LastRoad(0)) // last road of epoch 0 seeds epoch 2 got := <-done require.NoError(t, got.err) require.Equal(t, types.EpochIndex(2), got.duo.Current.EpochIndex()) diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index b5a06fbc4d..7e0229b0d8 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -31,14 +31,14 @@ func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.E // Limit to {0, 1}: GenRegistryAt for either value always includes epoch 0 // ([0] or [0,1]), so tests that build CommitQC chains from road index 0 // can still look up epoch 0 in the window. Higher values would require all - // such tests to anchor their chains at startEpoch*EpochLength. + // such tests to anchor their chains at FirstRoad(startEpoch). startEpoch := types.EpochIndex(rng.Intn(2)) //nolint:gosec r := makeRegistryAt(committee, firstBlock, startEpoch) return r, sks, startEpoch } // GenRegistryAt generates a Registry of the given committee size centered on startEpoch. -// Seeds [startEpoch-1, startEpoch] so DuoAt(startEpoch*EpochLength) works. +// Seeds [startEpoch-1, startEpoch] so DuoAt(FirstRoad(startEpoch)) works. // Intended for use in tests only. func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Registry, []types.SecretKey) { sks := utils.GenSliceN(rng, size, types.GenSecretKey) diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 1e066536f0..2b18dea5b5 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -108,7 +108,18 @@ func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, error) { if err != nil { return nil, fmt.Errorf("data.NewDataWAL(): %w", err) } - dataState, err := data.NewState(&data.Config{Registry: registry}, dataWAL) + // TODO(autobahn): Reading execution tip from app.LastBlockHeight() (Cosmos + // app state DB). Move this into Giga storage; stop depending on the app DB. + var lastExecuted atypes.GlobalBlockNumber + if cfg.App != nil { + if h := cfg.App.LastBlockHeight(); h > 0 { + lastExecuted = atypes.GlobalBlockNumber(h) //nolint:gosec // height is non-negative + } + } + dataState, err := data.NewState(&data.Config{ + Registry: registry, + LastExecutedBlock: lastExecuted, + }, dataWAL) if err != nil { return nil, fmt.Errorf("data.NewState(): %w", err) } diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 8f42500b84..c0ecc81910 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -190,9 +190,9 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { } // Short run stays in early epoch 0; AdvanceIfNeeded only seeds N+2 on the // last road, so epoch 2 must still be absent while {0,1} remain from setup. - _, err := giga0.data.Registry().EpochAt(2 * epoch.EpochLength) + _, err := giga0.data.Registry().EpochAt(epoch.FirstRoad(2)) require.Error(t, err, "epoch 2 should not be seeded before last road of epoch 0") - _, err = giga0.data.Registry().EpochAt(epoch.EpochLength) + _, err = giga0.data.Registry().EpochAt(epoch.FirstRoad(1)) require.NoError(t, err, "initial seeding should have registered epoch 1") return nil }) From a46ffa0a8a4c6709476774c9346a580877562e00 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 11:36:20 -0700 Subject: [PATCH 61/98] fix(autobahn): backpressure too-early CommitQC on Current window PushCommitQC waits on Current (not silent drop); stale roads log and return. PushAppVote uses the same early/late split as PushAppQC via waitEpochForRoad. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 62 +++++++--- .../internal/autobahn/avail/state_test.go | 107 ++++++++++++++++++ 2 files changed, 153 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 5d7b9276e5..baab61163c 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -225,8 +225,8 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error } // waitEpochForRoad blocks until roadIdx is in Prev|Current, backpressuring -// callers (e.g. peer streams) until this node catches up. -// Returns None if the road has fallen behind the window (stale). +// callers (e.g. peer streams) until this node catches up (too early). +// Returns None if the road has fallen behind the window (too late / stale). func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { if _, err := duo.EpochForRoad(roadIdx); err == nil { @@ -247,6 +247,26 @@ func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) ( return utils.None[*types.Epoch](), nil } +// waitCurrentForRoad blocks until roadIdx is in Current, backpressuring +// callers until Current catches up (too early). Returns None if the road has +// fallen behind Current (too late / stale). Unlike waitEpochForRoad, Prev is +// not admitted — CommitQCs are Current-only. +func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { + duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { + if duo.Current.RoadRange().Has(roadIdx) { + return true + } + return roadIdx < duo.Current.RoadRange().First + }) + if err != nil { + return utils.None[*types.Epoch](), err + } + if duo.Current.RoadRange().Has(roadIdx) { + return utils.Some(duo.Current), nil + } + return utils.None[*types.Epoch](), nil +} + // waitForAppQCEpoch blocks until latest AppQC is from epochIdx or later. func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex) error { for inner, ctrl := range s.inner.Lock() { @@ -315,7 +335,8 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi } // PushCommitQC pushes a CommitQC to the state. -// Waits for prior CommitQCs, and for AppQC of epoch N before accepting a +// Waits for prior CommitQCs, for Current to cover the road (too early blocks; +// too late / stale is dropped), and for AppQC of epoch N before accepting a // CommitQC from N+1 (avail tip at most one epoch ahead of the AppQC anchor). func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() @@ -324,13 +345,17 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - duo := s.epochDuo.Load() // CommitQCs are admitted for Current only; new-committee traffic starts - // after the boundary advances the duo. - ep := duo.Current - if !ep.RoadRange().Has(idx) { - logger.Info("dropping CommitQC: road outside Current", - slog.Uint64("road", uint64(idx)), "duo", duo.String()) + // after the boundary advances the duo. Too-early roads backpressure the + // caller; too-late (behind Current) are dropped. + epOpt, err := s.waitCurrentForRoad(ctx, idx) + if err != nil { + return err + } + ep, ok := epOpt.Get() + if !ok { + logger.Info("dropping stale CommitQC: road behind Current", + slog.Uint64("road", uint64(idx)), "duo", s.epochDuo.Load().String()) return nil } if err := qc.Verify(ep); err != nil { @@ -348,7 +373,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // Boundary: switch to the next epoch on Current's last CommitQC. // Resolve next duo off-lock (WaitForDuo). var nextDuo *types.EpochDuo - if idx+1 == duo.Current.RoadRange().Next { + if idx+1 == ep.RoadRange().Next { nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) if err != nil { return err @@ -375,14 +400,19 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // PushAppVote pushes an AppVote to the state. func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]) error { idx := v.Msg().Proposal().RoadIndex() - // A vote may arrive before its CommitQC advances the epoch window. + // A vote may arrive before its CommitQC advances the tip. if err := s.waitForCommitQC(ctx, idx); err != nil { return err } - ep, err := s.epochDuo.Load().EpochForRoad(idx) + // Too-early roads (ahead of Prev|Current) backpressure; too-late are dropped. + epOpt, err := s.waitEpochForRoad(ctx, idx) if err != nil { - logger.Info("dropping stale AppVote: road outside epoch window", - slog.Uint64("road", uint64(idx)), "err", err) + return err + } + ep, ok := epOpt.Get() + if !ok { + logger.Info("dropping stale AppVote: road behind epoch window", + slog.Uint64("road", uint64(idx)), "duo", s.epochDuo.Load().String()) return nil } committee := ep.Committee() @@ -436,8 +466,8 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ } ep, ok := epOpt.Get() if !ok { - logger.Info("dropping stale AppQC: road outside epoch window", - slog.Uint64("road", uint64(idx))) + logger.Info("dropping stale AppQC: road behind epoch window", + slog.Uint64("road", uint64(idx)), "duo", s.epochDuo.Load().String()) return nil } if err := appQC.Verify(ep.Committee()); err != nil { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index a6114ea692..6667e0d2cf 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -945,6 +945,113 @@ func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { require.False(t, ep.IsPresent()) } +func TestWaitCurrentForRoadFutureBlocks(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = state.waitCurrentForRoad(ctx, epoch.FirstRoad(1)) + require.ErrorIs(t, err, context.Canceled) +} + +func TestWaitCurrentForRoadStaleReturnsNone(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 2) + + ep, err := state.waitCurrentForRoad(t.Context(), 0) + require.NoError(t, err) + require.False(t, ep.IsPresent()) +} + +// TestWaitCurrentForRoadPrevNotAdmitted: a road in Prev is too late for +// Current-only admission (CommitQC), even though waitEpochForRoad would +// still resolve it. +func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) // Prev=0|Current=1 + + roadInPrev := types.RoadIndex(0) + epWin, err := state.waitEpochForRoad(t.Context(), roadInPrev) + require.NoError(t, err) + require.True(t, epWin.IsPresent(), "Prev|Current window still covers road 0") + + epCur, err := state.waitCurrentForRoad(t.Context(), roadInPrev) + require.NoError(t, err) + require.False(t, epCur.IsPresent(), "Current-only wait must treat Prev roads as too late") +} + +func TestPushCommitQCStaleDrops(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + lane := keys[0].Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block.Header()))}, + utils.None[*types.AppQC]()) + + registerDuoAtEpoch(state, 2) + + require.NoError(t, state.PushCommitQC(t.Context(), qc0)) + require.False(t, state.LastCommitQC().Load().IsPresent(), "stale CommitQC must not be admitted") +} + +func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { + registry, keys := epoch.GenRegistryAt(utils.TestRng(), 4, 0) // {0,1}; Current starts at epoch 0 + ep0 := utils.OrPanic1(registry.EpochAt(0)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // Satisfy waitForCommitQC(FirstRoad(1)-1) without pushing EpochLength QCs. + // Current remains epoch 0, so FirstRoad(1) is too early for waitCurrentForRoad. + // Minimal tip/future QCs are enough: we cancel before Verify. + tipQC := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0), + }))), + }) + state.markCommitQCsPersisted(tipQC) + + qc1 := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.FirstRoad(1), + }))), + }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushCommitQC(ctx, qc1), context.Canceled) +} + // TestPushAppQCPreviousEpoch verifies that a late AppQC whose road falls in // epoch N-1 is accepted after Current has advanced to epoch N. Its committee // is resolved from Prev (N-1), not Current (N). From 0d9e87b449a6bf6ec865b8792dae945f5e8474a2 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 11:42:35 -0700 Subject: [PATCH 62/98] refactor(autobahn): share epoch-window wait/drop helpers in avail Extract waitRoadInWindow and admitRoadOrDrop for Push* paths, and add EpochDuo WindowFirst / EpochOptForRoad / CurrentForRoad for lookups. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 25 +++++ .../autobahn/types/epoch_duo_test.go | 38 +++++++ .../internal/autobahn/avail/state.go | 99 ++++++++++--------- 3 files changed, 114 insertions(+), 48 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 4b0ff3187c..60a6e9743f 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -31,6 +31,31 @@ func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { return nil, fmt.Errorf("road %d not in window %v", roadIdx, w) } +// EpochOptForRoad is EpochForRoad as an Option (None when out of window). +func (w EpochDuo) EpochOptForRoad(roadIdx RoadIndex) utils.Option[*Epoch] { + if ep, err := w.EpochForRoad(roadIdx); err == nil { + return utils.Some(ep) + } + return utils.None[*Epoch]() +} + +// CurrentForRoad returns Current when roadIdx is in Current's range; else None. +// Unlike EpochOptForRoad, Prev is never admitted. +func (w EpochDuo) CurrentForRoad(roadIdx RoadIndex) utils.Option[*Epoch] { + if w.Current != nil && w.Current.RoadRange().Has(roadIdx) { + return utils.Some(w.Current) + } + return utils.None[*Epoch]() +} + +// WindowFirst is the earliest road still in Prev|Current. +func (w EpochDuo) WindowFirst() RoadIndex { + if prev, ok := w.Prev.Get(); ok { + return prev.RoadRange().First + } + return w.Current.RoadRange().First +} + // EpochForIndex returns Current or Prev by epoch index. func (w EpochDuo) EpochForIndex(idx EpochIndex) (*Epoch, error) { if w.Current != nil && w.Current.EpochIndex() == idx { diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 4afe792502..2c121164ea 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -76,3 +76,41 @@ func TestEpochForRoad_AbsentPrevSkipped(t *testing.T) { t.Fatal("EpochForRoad(50) with absent Prev expected error, got nil") } } + +func TestWindowFirst_WithPrev(t *testing.T) { + prev, current := testDuoEpochs(t) + w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + if got, want := w.WindowFirst(), prev.RoadRange().First; got != want { + t.Fatalf("WindowFirst() = %d, want %d", got, want) + } +} + +func TestWindowFirst_CurrentOnly(t *testing.T) { + _, current := testDuoEpochs(t) + w := types.EpochDuo{Current: current} + if got, want := w.WindowFirst(), current.RoadRange().First; got != want { + t.Fatalf("WindowFirst() = %d, want %d", got, want) + } +} + +func TestEpochOptForRoad(t *testing.T) { + prev, current := testDuoEpochs(t) + w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + if ep, ok := w.EpochOptForRoad(50).Get(); !ok || ep != prev { + t.Fatalf("EpochOptForRoad(50) = %v, want prev", ep) + } + if w.EpochOptForRoad(999).IsPresent() { + t.Fatal("EpochOptForRoad(999) should be None") + } +} + +func TestCurrentForRoad(t *testing.T) { + prev, current := testDuoEpochs(t) + w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + if ep, ok := w.CurrentForRoad(150).Get(); !ok || ep != current { + t.Fatalf("CurrentForRoad(150) = %v, want current", ep) + } + if w.CurrentForRoad(50).IsPresent() { + t.Fatal("CurrentForRoad(50) must not admit Prev") + } +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index baab61163c..2e5476b907 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -224,27 +224,35 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error return err } -// waitEpochForRoad blocks until roadIdx is in Prev|Current, backpressuring -// callers (e.g. peer streams) until this node catches up (too early). -// Returns None if the road has fallen behind the window (too late / stale). -func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { +// waitRoadInWindow blocks while roadIdx is ahead of the admitted window +// (too early / backpressure). Returns Some(epoch) when lookup admits the road, +// or None when roadIdx has fallen behind windowFirst (too late / stale). +func (s *State) waitRoadInWindow( + ctx context.Context, + roadIdx types.RoadIndex, + lookup func(types.EpochDuo) utils.Option[*types.Epoch], + windowFirst func(types.EpochDuo) types.RoadIndex, +) (utils.Option[*types.Epoch], error) { duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { - if _, err := duo.EpochForRoad(roadIdx); err == nil { + if lookup(duo).IsPresent() { return true } - first := duo.Current.RoadRange().First - if prev, ok := duo.Prev.Get(); ok { - first = prev.RoadRange().First - } - return roadIdx < first + return roadIdx < windowFirst(duo) }) if err != nil { return utils.None[*types.Epoch](), err } - if ep, err := duo.EpochForRoad(roadIdx); err == nil { - return utils.Some(ep), nil - } - return utils.None[*types.Epoch](), nil + return lookup(duo), nil +} + +// waitEpochForRoad blocks until roadIdx is in Prev|Current, backpressuring +// callers (e.g. peer streams) until this node catches up (too early). +// Returns None if the road has fallen behind the window (too late / stale). +func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { + return s.waitRoadInWindow(ctx, roadIdx, + func(duo types.EpochDuo) utils.Option[*types.Epoch] { return duo.EpochOptForRoad(roadIdx) }, + types.EpochDuo.WindowFirst, + ) } // waitCurrentForRoad blocks until roadIdx is in Current, backpressuring @@ -252,19 +260,32 @@ func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) ( // fallen behind Current (too late / stale). Unlike waitEpochForRoad, Prev is // not admitted — CommitQCs are Current-only. func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { - duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { - if duo.Current.RoadRange().Has(roadIdx) { - return true - } - return roadIdx < duo.Current.RoadRange().First - }) + return s.waitRoadInWindow(ctx, roadIdx, + func(duo types.EpochDuo) utils.Option[*types.Epoch] { return duo.CurrentForRoad(roadIdx) }, + func(duo types.EpochDuo) types.RoadIndex { return duo.Current.RoadRange().First }, + ) +} + +// admitRoadOrDrop waits for window admission. On stale it logs and returns +// (nil, nil) so Push* callers can drop without repeating the boilerplate. +// what is a short label for the log line (e.g. "CommitQC", "AppVote"). +func (s *State) admitRoadOrDrop( + ctx context.Context, + roadIdx types.RoadIndex, + what string, + wait func(context.Context, types.RoadIndex) (utils.Option[*types.Epoch], error), +) (*types.Epoch, error) { + epOpt, err := wait(ctx, roadIdx) if err != nil { - return utils.None[*types.Epoch](), err + return nil, err } - if duo.Current.RoadRange().Has(roadIdx) { - return utils.Some(duo.Current), nil + ep, ok := epOpt.Get() + if !ok { + logger.Info("dropping stale "+what+": road behind window", + slog.Uint64("road", uint64(roadIdx)), "duo", s.epochDuo.Load().String()) + return nil, nil } - return utils.None[*types.Epoch](), nil + return ep, nil } // waitForAppQCEpoch blocks until latest AppQC is from epochIdx or later. @@ -348,16 +369,10 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // CommitQCs are admitted for Current only; new-committee traffic starts // after the boundary advances the duo. Too-early roads backpressure the // caller; too-late (behind Current) are dropped. - epOpt, err := s.waitCurrentForRoad(ctx, idx) - if err != nil { + ep, err := s.admitRoadOrDrop(ctx, idx, "CommitQC", s.waitCurrentForRoad) + if err != nil || ep == nil { return err } - ep, ok := epOpt.Get() - if !ok { - logger.Info("dropping stale CommitQC: road behind Current", - slog.Uint64("road", uint64(idx)), "duo", s.epochDuo.Load().String()) - return nil - } if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } @@ -405,16 +420,10 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] return err } // Too-early roads (ahead of Prev|Current) backpressure; too-late are dropped. - epOpt, err := s.waitEpochForRoad(ctx, idx) - if err != nil { + ep, err := s.admitRoadOrDrop(ctx, idx, "AppVote", s.waitEpochForRoad) + if err != nil || ep == nil { return err } - ep, ok := epOpt.Get() - if !ok { - logger.Info("dropping stale AppVote: road behind epoch window", - slog.Uint64("road", uint64(idx)), "duo", s.epochDuo.Load().String()) - return nil - } committee := ep.Committee() if err := v.VerifySig(committee); err != nil { return fmt.Errorf("v.VerifySig(): %w", err) @@ -460,16 +469,10 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ } } idx := commitQC.Proposal().Index() - epOpt, err := s.waitEpochForRoad(ctx, idx) - if err != nil { + ep, err := s.admitRoadOrDrop(ctx, idx, "AppQC", s.waitEpochForRoad) + if err != nil || ep == nil { return err } - ep, ok := epOpt.Get() - if !ok { - logger.Info("dropping stale AppQC: road behind epoch window", - slog.Uint64("road", uint64(idx)), "duo", s.epochDuo.Load().String()) - return nil - } if err := appQC.Verify(ep.Committee()); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) } From 8228350baaaca9d5bd92afa0efbbe14a8f34feab Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 12:14:12 -0700 Subject: [PATCH 63/98] fix(autobahn): stop prune from silently inserting CommitQCs Move tipcut pushBack to newInner/PushAppQC after prune advances next. PushAppQC also advances the duo when tipcut-inserting a Current boundary QC. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 23 ++++++++++-------- .../internal/autobahn/avail/inner_test.go | 8 +++---- .../internal/autobahn/avail/state.go | 24 +++++++++++++++++-- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 4ee73b7ee5..0997d53ce4 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -98,9 +98,9 @@ func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailStat return i, nil } - // Apply the persisted prune anchor first: prune() positions all queues - // (commitQCs, blocks, votes) so that subsequent pushBack calls insert - // at the correct indices without needing reset(). + // Apply the persisted prune anchor first: prune() advances watermarks on + // all queues (commitQCs, blocks, votes). Tipcut CommitQC insert is + // explicit below — prune never silently pushBacks. // prune also sets appVotes.first from the anchor CommitQC. if anchor, ok := l.pruneAnchor.Get(); ok { logger.Info("loaded persisted prune anchor", @@ -110,6 +110,11 @@ func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailStat if _, err := i.prune(anchor.AppQC, anchor.CommitQC); err != nil { return nil, fmt.Errorf("prune: %w", err) } + // prune advances next to idx; pushBack the justifying tipcut QC. + if i.commitQCs.next == anchor.CommitQC.Proposal().Index() { + i.commitQCs.pushBack(anchor.CommitQC) + metrics.ObserveCommitQC(anchor.CommitQC) + } for lane, ls := range i.lanes { ls.persistedBlockStart = anchor.CommitQC.LaneRange(lane).First() } @@ -121,8 +126,8 @@ func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailStat return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } - // Restore persisted CommitQCs. prune() may have already pushed the - // anchor's CommitQC, so skip entries below commitQCs.next. + // Restore persisted CommitQCs. Tipcut insert above may already hold the + // anchor; skip entries below commitQCs.next. for _, lqc := range l.commitQCs { if lqc.Index < i.commitQCs.next { continue @@ -190,7 +195,9 @@ func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { i.epochDuo.Store(nextDuo) } -// prune advances the state to account for a new AppQC/CommitQC pair. +// prune advances watermarks for a new AppQC/CommitQC pair (commitQCs/appVotes/ +// lane queues). It does not insert CommitQCs — callers that tipcut-catch-up +// must pushBack after prune when next==idx. // Returns true if pruning occurred, false if the QC was stale. func (i *inner) prune(appQC *types.AppQC, commitQC *types.CommitQC) (bool, error) { idx := appQC.Proposal().RoadIndex() @@ -203,10 +210,6 @@ func (i *inner) prune(appQC *types.AppQC, commitQC *types.CommitQC) (bool, error i.latestAppQC = utils.Some(appQC) metrics.ObserveAppQC(appQC) i.commitQCs.prune(idx) - if i.commitQCs.next == idx { - i.commitQCs.pushBack(commitQC) - metrics.ObserveCommitQC(commitQC) - } i.appVotes.prune(commitQC.GlobalRange().First) for lane, ls := range i.lanes { lr := commitQC.LaneRange(lane) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 1ed4be1a89..d282d51d41 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -482,7 +482,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) - // prune() pushes the anchor's CommitQC into the queue. + // tipcut insert after prune places the anchor's CommitQC. require.Equal(t, types.RoadIndex(5), inner.commitQCs.first) require.Equal(t, types.RoadIndex(6), inner.commitQCs.next) require.NoError(t, utils.TestDiff(qcs[5], inner.commitQCs.q[5])) @@ -511,7 +511,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) - // prune() should push the anchor's CommitQC into the queue. + // tipcut insert after prune places the anchor's CommitQC. require.Equal(t, types.RoadIndex(3), inner.commitQCs.first) require.Equal(t, types.RoadIndex(4), inner.commitQCs.next) require.NoError(t, utils.TestDiff(qcs[3], inner.commitQCs.q[3])) @@ -651,7 +651,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) - // prune(3) pushes QC@3 (next=4). Indices 1,2,3 are skipped. 4,5 pushed. + // tipcut insert places QC@3 (next=4). Indices 1,2,3 are skipped. 4,5 pushed. require.Equal(t, types.RoadIndex(3), inner.commitQCs.first) require.Equal(t, types.RoadIndex(6), inner.commitQCs.next) latest, ok := inner.latestCommitQC.Load().Get() @@ -664,7 +664,7 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 4) // Anchor at index 2. Loaded commitQCs are [2, 3, 5] — gap at 4. - // After prune(2), next=3. Index 2 is skipped, 3 pushed (next=4), + // After prune+tipcut insert at 2, next=3. Index 2 is skipped, 3 pushed (next=4), // then 5 != 4 → error. qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 2e5476b907..c64ed1dcf0 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -490,14 +490,34 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ if !commitQC.GlobalRange().Has(appQC.Proposal().GlobalNumber()) { return fmt.Errorf("appQC GlobalNumber not in commitQC range") } + // Tipcut insert of a boundary CommitQC must slide Current like PushCommitQC. + var nextDuo *types.EpochDuo + if idx+1 == ep.RoadRange().Next { + nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) + if err != nil { + return err + } + nextDuo = &nt + } for inner, ctrl := range s.inner.Lock() { updated, err := inner.prune(appQC, commitQC) if err != nil { return err } - if updated { - ctrl.Updated() + if !updated { + return nil } + // prune advances pointers first; only then can pushBack land at idx. + if inner.commitQCs.next == idx { + // Slide duo before insert when this tipcut closes Current (same + // order as PushCommitQC). Skip if Current already moved on. + if nextDuo != nil && inner.epochDuo.Load().Current.RoadRange().Has(idx) { + inner.advanceEpoch(*nextDuo) + } + inner.commitQCs.pushBack(commitQC) + metrics.ObserveCommitQC(commitQC) + } + ctrl.Updated() } return nil } From c19c75b723ef163f26080a5fdaa26720aa7f689f Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 12:21:49 -0700 Subject: [PATCH 64/98] fix(autobahn): seed E+2 on restart when execution tip closes epoch E Mirror live AdvanceIfNeeded so WaitForDuo at the E+1 boundary does not hang after a restart that never re-executes LastRoad(E). Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state_test.go | 2 +- sei-tendermint/internal/autobahn/epoch/registry.go | 11 ++++++++--- .../internal/autobahn/epoch/registry_test.go | 11 +++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 6667e0d2cf..0eed49748c 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1095,7 +1095,7 @@ func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { _, err := registry.DuoAt(tip2) require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") - // CommitQC closing epoch 1 opens {0,1}; execution on that road adds 2. + // CommitQC closing epoch 1 opens {0,1}; closing-road execution adds 2 and 3. registry.SetupInitialDuo( utils.Some(epoch.LastRoad(1)), utils.Some(epoch.LastRoad(1)), diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 8b1ae74bdf..f76d87ebf4 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -73,7 +73,8 @@ func NewRegistry( // // lastCommitQC in epoch N registers {N-1 (clamped), N} — the Prev|Current window // needed to DuoAt/verify that tip. lastExecutedRoad may extend the high end -// only (never shrink): a road in epoch E implies E+1 is present. If execution is +// only (never shrink): a road in epoch E implies E+1 is present; the closing +// road of E also implies E+2 (same as live AdvanceIfNeeded). If execution is // past the CommitQC tip, that is a bug — warn and ignore it. // // None/None (fresh start) seeds {0, 1}. Idempotent for existing entries. @@ -102,8 +103,12 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t slog.Uint64("commit_qc_road", uint64(commitRoad))) } else { tipEpoch := IndexForRoad(road) - // End of tipEpoch-1 already ran → tipEpoch+1 is present. + // Mirror AdvanceIfNeeded: any road in E implies E+1 (seeded at end + // of E-1); the closing road of E also seeds E+2. high := tipEpoch + 1 + if road == LastRoad(tipEpoch) { + high = tipEpoch + 2 + } if !haveWindow { // Execution-only: open Prev|Current around the executed tip, then // extend forward. @@ -179,7 +184,7 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type // AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed. // Earlier roads in the epoch are a no-op. Restart SetupInitialDuo restores the -// N+1 that this would have seeded at the end of N-1. Committee for N+2 is +// same lookahead (closing execution tip → N+2). Committee for N+2 is // currently the genesis committee. // TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index fc604aed4c..d0f351c5e6 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -112,18 +112,17 @@ func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { } } -func TestSetupInitialDuo_ExecutionClosingStillOnlyAddsNext(t *testing.T) { +func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { r, _ := makeRegistry(t) - // Closing road of epoch 5 is still "execution in 5" → high end 6, not 7. - // (tipEpoch+2 would mean executing past CommitQC; we do not handle that.) + // Closing road of epoch 5 mirrors AdvanceIfNeeded → high end 7. r.SetupInitialDuo(utils.Some(LastRoad(5)), utils.Some(LastRoad(5))) - for _, idx := range []types.EpochIndex{4, 5, 6} { + for _, idx := range []types.EpochIndex{4, 5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after closing execution: %v", idx, err) } } - if _, err := r.EpochAt(FirstRoad(7)); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present from closing-road execution") + if _, err := r.EpochAt(FirstRoad(8)); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present from closing-road execution") } } From 5ead0ee58cde5f37128a4a273ff99905aa0a1309 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 12:32:36 -0700 Subject: [PATCH 65/98] refactor(autobahn): replay AdvanceIfNeeded from SetupInitialDuo Restart opens Prev|Current from CommitQC/execution tip, then uses the same AdvanceIfNeeded path as live for E+1/E+2 lookahead instead of duplicating it. Co-authored-by: Cursor --- .../internal/autobahn/epoch/registry.go | 37 ++++++++++--------- .../internal/autobahn/epoch/registry_test.go | 6 +-- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index f76d87ebf4..1858acaaae 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -72,10 +72,10 @@ func NewRegistry( // data across an unseeded epoch). // // lastCommitQC in epoch N registers {N-1 (clamped), N} — the Prev|Current window -// needed to DuoAt/verify that tip. lastExecutedRoad may extend the high end -// only (never shrink): a road in epoch E implies E+1 is present; the closing -// road of E also implies E+2 (same as live AdvanceIfNeeded). If execution is -// past the CommitQC tip, that is a bug — warn and ignore it. +// needed to DuoAt/verify that tip. lastExecutedRoad then replays live +// AdvanceIfNeeded: closing E-1 restores E+1, and a closing execution tip +// restores E+2. If execution is past the CommitQC tip, that is a bug — warn +// and ignore it (no AdvanceIfNeeded). // // None/None (fresh start) seeds {0, 1}. Idempotent for existing entries. // @@ -85,6 +85,7 @@ func NewRegistry( func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[types.RoadIndex]) { var windowFirst, windowLast types.EpochIndex haveWindow := false + executedForAdvance := utils.None[types.RoadIndex]() if road, ok := lastCommitQC.Get(); ok { tipEpoch := IndexForRoad(road) @@ -103,15 +104,8 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t slog.Uint64("commit_qc_road", uint64(commitRoad))) } else { tipEpoch := IndexForRoad(road) - // Mirror AdvanceIfNeeded: any road in E implies E+1 (seeded at end - // of E-1); the closing road of E also seeds E+2. - high := tipEpoch + 1 - if road == LastRoad(tipEpoch) { - high = tipEpoch + 2 - } if !haveWindow { - // Execution-only: open Prev|Current around the executed tip, then - // extend forward. + // Execution-only: open Prev|Current around the executed tip. windowFirst = 0 if tipEpoch >= 1 { windowFirst = tipEpoch - 1 @@ -119,9 +113,7 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t windowLast = tipEpoch haveWindow = true } - if high > windowLast { - windowLast = high - } + executedForAdvance = utils.Some(road) } } @@ -137,6 +129,15 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present } } + + // Replay live lookahead from the execution tip (same AdvanceIfNeeded policy). + if road, ok := executedForAdvance.Get(); ok { + tipEpoch := IndexForRoad(road) + if tipEpoch >= 1 { + r.AdvanceIfNeeded(LastRoad(tipEpoch - 1)) // seeds tipEpoch+1 + } + r.AdvanceIfNeeded(road) // seeds tipEpoch+2 iff closing + } } // FirstBlock returns the first global block number of the genesis epoch. @@ -183,9 +184,9 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type } // AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed. -// Earlier roads in the epoch are a no-op. Restart SetupInitialDuo restores the -// same lookahead (closing execution tip → N+2). Committee for N+2 is -// currently the genesis committee. +// Earlier roads in the epoch are a no-op. Restart SetupInitialDuo replays this +// for the restored execution tip (and for LastRoad(E-1) to restore E+1). +// Committee for N+2 is currently the genesis committee. // TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { tipEpoch := IndexForRoad(roadIndex) diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index d0f351c5e6..e24c0b4d39 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -100,7 +100,7 @@ func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { r, _ := makeRegistry(t) - // CommitQC in epoch 5 opens {4,5}; execution mid-5 adds 6. + // CommitQC in epoch 5 opens {4,5}; mid-5 execution replays AdvanceIfNeeded(LastRoad(4)) → 6. r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(midRoad(5))) for _, idx := range []types.EpochIndex{4, 5, 6} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { @@ -114,7 +114,7 @@ func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { r, _ := makeRegistry(t) - // Closing road of epoch 5 mirrors AdvanceIfNeeded → high end 7. + // Closing road of epoch 5: AdvanceIfNeeded(LastRoad(4))→6, AdvanceIfNeeded(LastRoad(5))→7. r.SetupInitialDuo(utils.Some(LastRoad(5)), utils.Some(LastRoad(5))) for _, idx := range []types.EpochIndex{4, 5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { @@ -149,7 +149,7 @@ func TestSetupInitialDuo_ExecutionDoesNotShrinkCommitWindow(t *testing.T) { t.Fatalf("EpochAt(epoch %d) must remain after lagging execution: %v", idx, err) } } - // Execution mid-3 extends high end to 4 (already present); not to 6. + // Execution mid-3 replays AdvanceIfNeeded(LastRoad(2)) (epoch 4 already present); not 6. if _, err := r.EpochAt(FirstRoad(6)); err == nil { t.Fatal("EpochAt(epoch 6) should not be present when execution lags in epoch 3") } From 99a4ee8d1f099c1787b714174d292868547f00f2 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 12:46:07 -0700 Subject: [PATCH 66/98] fix(autobahn): seed N+1 when restart CommitQC tip closes epoch N DuoAt(FirstRoad(N+1)) is required for data/avail tipcut after LastRoad(N); replay AdvanceIfNeeded(LastRoad(N-1)) so startup does not depend on execution. Co-authored-by: Cursor --- .../internal/autobahn/epoch/registry.go | 22 +++++++++++++++---- .../internal/autobahn/epoch/registry_test.go | 17 ++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 1858acaaae..e2ac308dc3 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -72,10 +72,12 @@ func NewRegistry( // data across an unseeded epoch). // // lastCommitQC in epoch N registers {N-1 (clamped), N} — the Prev|Current window -// needed to DuoAt/verify that tip. lastExecutedRoad then replays live -// AdvanceIfNeeded: closing E-1 restores E+1, and a closing execution tip -// restores E+2. If execution is past the CommitQC tip, that is a bug — warn -// and ignore it (no AdvanceIfNeeded). +// needed to DuoAt/verify that tip. If that tip is LastRoad(N), the live tipcut +// is FirstRoad(N+1), so AdvanceIfNeeded(LastRoad(N-1)) restores N+1 (same as +// the WaitForDuo that completed when the closing QC was applied). +// lastExecutedRoad then replays live AdvanceIfNeeded for further lookahead +// (closing E → E+2). If execution is past the CommitQC tip, that is a bug — +// warn and ignore it (no execution AdvanceIfNeeded). // // None/None (fresh start) seeds {0, 1}. Idempotent for existing entries. // @@ -86,6 +88,7 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t var windowFirst, windowLast types.EpochIndex haveWindow := false executedForAdvance := utils.None[types.RoadIndex]() + closingCommit := utils.None[types.RoadIndex]() if road, ok := lastCommitQC.Get(); ok { tipEpoch := IndexForRoad(road) @@ -95,6 +98,9 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t } windowLast = tipEpoch haveWindow = true + if road == LastRoad(tipEpoch) { + closingCommit = utils.Some(road) + } } if road, ok := lastExecutedRoad.Get(); ok { @@ -130,6 +136,14 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t } } + // Closing CommitQC tipcut is FirstRoad(N+1); restore N+1 even if execution lags. + if road, ok := closingCommit.Get(); ok { + tipEpoch := IndexForRoad(road) + if tipEpoch >= 1 { + r.AdvanceIfNeeded(LastRoad(tipEpoch - 1)) // seeds tipEpoch+1 + } + } + // Replay live lookahead from the execution tip (same AdvanceIfNeeded policy). if road, ok := executedForAdvance.Get(); ok { tipEpoch := IndexForRoad(road) diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index e24c0b4d39..5de81d0986 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -98,6 +98,23 @@ func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { } } +func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { + r, _ := makeRegistry(t) + // Closing CommitQC tipcut is FirstRoad(6); need epoch 6 for DuoAt without execution. + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(LastRoad(5))) + for _, idx := range []types.EpochIndex{4, 5, 6} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after closing CommitQC: %v", idx, err) + } + } + if _, err := r.DuoAt(FirstRoad(6)); err != nil { + t.Fatalf("DuoAt(FirstRoad(6)) after closing CommitQC: %v", err) + } + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present from CommitQC closing alone") + } +} + func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { r, _ := makeRegistry(t) // CommitQC in epoch 5 opens {4,5}; mid-5 execution replays AdvanceIfNeeded(LastRoad(4)) → 6. From efcc6311bfbfb937d8741f7234626004cee6d93e Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 13:00:22 -0700 Subject: [PATCH 67/98] refactor(autobahn): express restart seeding via EnsureEpoch helpers SetupInitialDuo opens Prev|Current then EnsureDuoAt(tipcut) and EnsureAfterExecuted; AdvanceIfNeeded becomes EnsureEpoch(N+2) on close. Co-authored-by: Cursor --- .../internal/autobahn/epoch/registry.go | 87 ++++++++++--------- .../internal/autobahn/epoch/registry_test.go | 8 +- 2 files changed, 51 insertions(+), 44 deletions(-) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index e2ac308dc3..19c0271120 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -71,14 +71,12 @@ func NewRegistry( // data.NewState (avail/consensus rely on that window; their tips must not lead // data across an unseeded epoch). // -// lastCommitQC in epoch N registers {N-1 (clamped), N} — the Prev|Current window -// needed to DuoAt/verify that tip. If that tip is LastRoad(N), the live tipcut -// is FirstRoad(N+1), so AdvanceIfNeeded(LastRoad(N-1)) restores N+1 (same as -// the WaitForDuo that completed when the closing QC was applied). -// lastExecutedRoad then replays live AdvanceIfNeeded for further lookahead -// (closing E → E+2). If execution is past the CommitQC tip, that is a bug — -// warn and ignore it (no execution AdvanceIfNeeded). +// 1. Open Prev|Current from lastCommitQC (or execution-only / fresh {0,1}). +// 2. EnsureDuoAt(commit tipcut) so data/avail DuoAt(Index+1) works — including +// when the tip closes epoch N (tipcut needs N+1 even if execution lags). +// 3. EnsureAfterExecuted(lastExecuted) restores live AdvanceIfNeeded lookahead. // +// If execution is past the CommitQC tip, that is a bug — warn and ignore it. // None/None (fresh start) seeds {0, 1}. Idempotent for existing entries. // // TODO(autobahn): lastExecutedRoad is derived from app.LastBlockHeight() (Cosmos @@ -88,7 +86,6 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t var windowFirst, windowLast types.EpochIndex haveWindow := false executedForAdvance := utils.None[types.RoadIndex]() - closingCommit := utils.None[types.RoadIndex]() if road, ok := lastCommitQC.Get(); ok { tipEpoch := IndexForRoad(road) @@ -98,9 +95,6 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t } windowLast = tipEpoch haveWindow = true - if road == LastRoad(tipEpoch) { - closingCommit = utils.Some(road) - } } if road, ok := lastExecutedRoad.Get(); ok { @@ -136,21 +130,11 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t } } - // Closing CommitQC tipcut is FirstRoad(N+1); restore N+1 even if execution lags. - if road, ok := closingCommit.Get(); ok { - tipEpoch := IndexForRoad(road) - if tipEpoch >= 1 { - r.AdvanceIfNeeded(LastRoad(tipEpoch - 1)) // seeds tipEpoch+1 - } + if road, ok := lastCommitQC.Get(); ok { + r.EnsureDuoAt(road + 1) // operating tipcut after last CommitQC } - - // Replay live lookahead from the execution tip (same AdvanceIfNeeded policy). if road, ok := executedForAdvance.Get(); ok { - tipEpoch := IndexForRoad(road) - if tipEpoch >= 1 { - r.AdvanceIfNeeded(LastRoad(tipEpoch - 1)) // seeds tipEpoch+1 - } - r.AdvanceIfNeeded(road) // seeds tipEpoch+2 iff closing + r.EnsureAfterExecuted(road) } } @@ -197,31 +181,54 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type return epoch, nil } -// AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed. -// Earlier roads in the epoch are a no-op. Restart SetupInitialDuo replays this -// for the restored execution tip (and for LastRoad(E-1) to restore E+1). -// Committee for N+2 is currently the genesis committee. -// TODO: pass the real N+2 committee once execution derives it. -func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { - tipEpoch := IndexForRoad(roadIndex) - next := FirstRoad(tipEpoch + 1) - if roadIndex+1 != next { - return // not the last road of its epoch - } - nextNextIdx := tipEpoch + 2 - // Fast path: epoch already seeded. +// EnsureEpoch registers a genesis-committee placeholder for idx if missing. +func (r *Registry) EnsureEpoch(idx types.EpochIndex) { for s := range r.state.RLock() { - if _, ok := s.m[nextNextIdx]; ok { + if _, ok := s.m[idx]; ok { return } } for s := range r.state.Lock() { - if _, ok := s.m[nextNextIdx]; !ok { - _, _ = r.makeEpoch(s, nextNextIdx) //nolint:errcheck // genesis always present + if _, ok := s.m[idx]; !ok { + _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present } } } +// EnsureDuoAt ensures epochs needed for DuoAt(road) (Current, and Prev when +// center > 0). +func (r *Registry) EnsureDuoAt(road types.RoadIndex) { + center := IndexForRoad(road) + if center > 0 { + r.EnsureEpoch(center - 1) + } + r.EnsureEpoch(center) +} + +// EnsureAfterExecuted restores the registry lookahead live AdvanceIfNeeded +// would have produced once road was executed: epoch E+1 (end of E-1 already +// ran), and E+2 if road is the closing road of E. +func (r *Registry) EnsureAfterExecuted(road types.RoadIndex) { + tipEpoch := IndexForRoad(road) + r.EnsureEpoch(tipEpoch + 1) + if road == LastRoad(tipEpoch) { + r.EnsureEpoch(tipEpoch + 2) + } +} + +// AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed. +// Earlier roads in the epoch are a no-op. Restart uses EnsureAfterExecuted / +// EnsureDuoAt instead of replaying this with synthetic LastRoad tips. +// Committee for N+2 is currently the genesis committee. +// TODO: pass the real N+2 committee once execution derives it. +func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { + tipEpoch := IndexForRoad(roadIndex) + if roadIndex != LastRoad(tipEpoch) { + return + } + r.EnsureEpoch(tipEpoch + 2) +} + // DuoAt returns the EpochDuo centered on the epoch containing roadIndex. // Current must already be present; returns an error if missing. Prev is absent // only when Current is epoch 0. diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 5de81d0986..c299530790 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -100,7 +100,7 @@ func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { r, _ := makeRegistry(t) - // Closing CommitQC tipcut is FirstRoad(6); need epoch 6 for DuoAt without execution. + // Closing CommitQC tipcut is FirstRoad(6); EnsureDuoAt restores epoch 6. r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(LastRoad(5))) for _, idx := range []types.EpochIndex{4, 5, 6} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { @@ -117,7 +117,7 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { r, _ := makeRegistry(t) - // CommitQC in epoch 5 opens {4,5}; mid-5 execution replays AdvanceIfNeeded(LastRoad(4)) → 6. + // CommitQC in epoch 5 opens {4,5}; mid-5 EnsureAfterExecuted → epoch 6. r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(midRoad(5))) for _, idx := range []types.EpochIndex{4, 5, 6} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { @@ -131,7 +131,7 @@ func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { r, _ := makeRegistry(t) - // Closing road of epoch 5: AdvanceIfNeeded(LastRoad(4))→6, AdvanceIfNeeded(LastRoad(5))→7. + // Closing execution: EnsureAfterExecuted → 6 and 7. r.SetupInitialDuo(utils.Some(LastRoad(5)), utils.Some(LastRoad(5))) for _, idx := range []types.EpochIndex{4, 5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { @@ -166,7 +166,7 @@ func TestSetupInitialDuo_ExecutionDoesNotShrinkCommitWindow(t *testing.T) { t.Fatalf("EpochAt(epoch %d) must remain after lagging execution: %v", idx, err) } } - // Execution mid-3 replays AdvanceIfNeeded(LastRoad(2)) (epoch 4 already present); not 6. + // Lagging mid-3 EnsureAfterExecuted → epoch 4 only; not 6. if _, err := r.EpochAt(FirstRoad(6)); err == nil { t.Fatal("EpochAt(epoch 6) should not be present when execution lags in epoch 3") } From 1d7e82fdf966c978e0859a78e48f9e7e3a148e37 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 13:15:01 -0700 Subject: [PATCH 68/98] docs(autobahn): note CommitQC tip anchors restart when execution QC pruned Clarify that roadForGlobal None skips EnsureAfterExecuted only; the CommitQC tipcut window remains authoritative for data/avail/consensus startup. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/state.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 935cf051ad..bd647ed905 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -309,7 +309,11 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { inner.skipTo(dataFirst) } // Seed epochs once here (avail/consensus do not call SetupInitialDuo). - // CommitQC tip opens Prev|Current; execution tip may extend the high end. + // CommitQC tip is the authoritative restart window (EnsureDuoAt tipcut). + // Execution tip only adds AdvanceIfNeeded lookahead via EnsureAfterExecuted; + // if LastExecutedBlock's QC was pruned from the WAL, roadForGlobal is None + // and that lookahead is skipped — live AdvanceIfNeeded re-seeds forward. + // Consensus/avail tips must not lead this window across an unseeded epoch. // // TODO(autobahn): LastExecutedBlock comes from app.LastBlockHeight() (app // state DB). Move execution tip into Giga storage; stop reading the app DB. @@ -387,7 +391,8 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { } // roadForGlobal returns the CommitQC road covering global block n, if present -// in the loaded WAL QCs. None when n==0 or the QC was pruned / not loaded. +// in the loaded WAL QCs. None when n==0 or the QC was pruned / not loaded +// (SetupInitialDuo then skips EnsureAfterExecuted; CommitQC tip still anchors). func roadForGlobal(qcs []*types.FullCommitQC, n types.GlobalBlockNumber) utils.Option[types.RoadIndex] { if n == 0 { return utils.None[types.RoadIndex]() From a44607e41e60a9afca61fbb485788b5239f7a7c0 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 13:36:55 -0700 Subject: [PATCH 69/98] fix(autobahn): guard missing lanes in avail headers() Return ErrPruned instead of panicking when a lane is absent, matching other accessors ahead of lane expiry. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index c64ed1dcf0..d5d22bd8ee 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -654,7 +654,11 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc want := lr.LastHash() headers := make([]*types.BlockHeader, lr.Next()-lr.First()) for inner, ctrl := range s.inner.Lock() { - q := inner.lanes[lr.Lane()].votes + ls, ok := inner.lanes[lr.Lane()] + if !ok { + return nil, data.ErrPruned + } + q := ls.votes for i := range headers { n := lr.Next() - types.BlockNumber(i) - 1 //nolint:gosec // i is bounded by len(headers) which is a small block range; no overflow risk for { From f16bb0e6e0aaa8e83390e37764d5afb879fb56ef Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 19:34:55 -0700 Subject: [PATCH 70/98] fix(autobahn): discard unverifiable data WAL QCs on restart Keep only a contiguous EpochAt-verifiable prefix; break on the first missing epoch, warn, and truncate WAL cursors. Past that gap needs state sync once committees come from execution. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/testonly.go | 16 ++++++ .../internal/autobahn/data/state.go | 49 ++++++++++++++++--- .../internal/autobahn/data/state_test.go | 48 ++++++++++++++++++ .../internal/autobahn/epoch/testonly.go | 22 +++++++++ 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index 151e8f960f..cffc89e42e 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -311,6 +311,22 @@ func ProposalAt(ep *Epoch, view View) *Proposal { return newProposal(view, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) } +// FullCommitQCAt builds a signed FullCommitQC at an explicit road covering a +// single global block at globalFirst (one synthetic lane header). For WAL / +// epoch-admission tests that need contiguous GlobalRanges at sparse roads. +func FullCommitQCAt(ep *Epoch, keys []SecretKey, road RoadIndex, globalFirst GlobalBlockNumber) *FullCommitQC { + lane := ep.Committee().Lanes().At(0) + payload := utils.OrPanic1(PayloadBuilder{CreatedAt: time.Time{}}.Build()) + header := NewBlock(lane, 0, BlockHeaderHash{}, payload).Header() + lr := NewLaneRange(lane, 0, utils.Some(header)) + p := newProposal(View{EpochIndex: ep.EpochIndex(), Index: road}, time.Time{}, []*LaneRange{lr}, utils.None[*AppProposal](), globalFirst) + vs := make([]*Signed[*CommitVote], len(keys)) + for i, k := range keys { + vs[i] = Sign(k, NewCommitVote(p)) + } + return NewFullCommitQC(NewCommitQC(vs), []*BlockHeader{header}) +} + // GenProposalForEpoch generates a Proposal at a specific view whose epochIndex, // firstBlock, and lane IDs are all consistent with ep. Use in tests that verify // QCs against a known Epoch. diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index bd647ed905..e415515694 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" @@ -12,8 +13,11 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" + "github.com/sei-protocol/seilog" ) +var logger = seilog.NewLogger("tendermint", "internal", "autobahn", "data") + const blocksCacheSize = 4000 // ErrNotFound is returned when the resource is not found. @@ -324,12 +328,26 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { } lastExecuted := roadForGlobal(loadedQCs, cfg.LastExecutedBlock) cfg.Registry.SetupInitialDuo(lastExecuted, lastQC) - // Restore QCs. insertQC handles partially pruned QCs (range starts - // before inner.first) by skipping the pruned prefix. + // Restore only a contiguous WAL QC prefix we can verify against the seeded + // epoch window. QCs are consecutive: the first unverifiable entry ends + // restore — nothing after it is kept (that would skip a hole). + // + // SetupInitialDuo opens Prev|Current around the CommitQC tip (plus limited + // lookahead). Older epochs are intentionally not registered. Today that is + // mostly a retention/prune invariant; in the future committees come from + // execution — if we cannot execute epoch N we do not have the committee for + // epoch N+2. Discard unverifiable entries (warn loudly) rather than invent a + // committee. The only way to recover past that gap is state sync from a + // trusted source. for _, qc := range loadedQCs { - ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) + road := qc.QC().Proposal().Index() + ep, err := cfg.Registry.EpochAt(road) if err != nil { - return nil, fmt.Errorf("load QC from WAL: epoch lookup: %w", err) + logger.Error("discarding unverifiable WAL CommitQCs from here (epoch not registered; consecutive QCs required; state sync to recover further)", + slog.Uint64("road", uint64(road)), + slog.Uint64("kept_next_qc", uint64(inner.nextQC)), + slog.String("err", err.Error())) + break } if err := inner.insertQC(qc, ep); err != nil { return nil, fmt.Errorf("load QC from WAL: %w", err) @@ -339,7 +357,7 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { expectedBlock := inner.first for _, lb := range dataWAL.Blocks.ConsumeLoaded() { if lb.Number < inner.first || lb.Number >= inner.nextQC { - continue // outside QC range (stale from reconcile) + continue // outside QC range (stale from reconcile / discarded QCs) } if lb.Number != expectedBlock { return nil, fmt.Errorf("block gap in WAL: expected %d, got %d", expectedBlock, lb.Number) @@ -348,7 +366,12 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { qc := inner.qcs[lb.Number] ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) if err != nil { - return nil, fmt.Errorf("load block %d from WAL: epoch lookup: %w", lb.Number, err) + // QC was admitted above; missing epoch here is unexpected — stop + // contiguous block restore and keep cursors at the last good block. + logger.Error("discarding WAL blocks after trusted prefix (epoch not registered)", + slog.Uint64("block", uint64(lb.Number)), + slog.String("err", err.Error())) + break } if err := lb.Block.Verify(ep.Committee()); err != nil { return nil, fmt.Errorf("load block %d from WAL: verify: %w", lb.Number, err) @@ -363,8 +386,18 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { } // Data loaded from WALs was already persisted in the previous run. inner.nextBlockToPersist = inner.nextBlock - // WAL cursor consistency was resolved by DataWAL.reconcile at construction. - // Verify the blocks persister cursor is not behind inner.nextBlock. + // Truncate disk to the trusted restore window so the next restart does not + // reload discarded QCs/blocks, and blocks cursor stays in sequence. + if inner.first > cfg.Registry.FirstBlock() { + if err := dataWAL.TruncateBefore(inner.first); err != nil { + return nil, fmt.Errorf("truncate WAL to trusted restore window: %w", err) + } + } + if dataWAL.Blocks.Next() < inner.nextBlock { + if err := dataWAL.Blocks.TruncateBefore(inner.nextBlock); err != nil { + return nil, fmt.Errorf("advance blocks WAL cursor to %d: %w", inner.nextBlock, err) + } + } if dataWAL.Blocks.Next() < inner.nextBlock { return nil, fmt.Errorf("blocks WAL cursor %d behind inner.nextBlock %d after reconciliation", dataWAL.Blocks.Next(), inner.nextBlock) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index c834463f89..79da943ce2 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -1164,3 +1164,51 @@ func TestPushQC_AdvancesEpochDuoAtBoundary(t *testing.T) { } }) } + +// TestNewState_BreaksOnUnverifiableWALQC covers restart when the CommitQC WAL +// hits an epoch outside SetupInitialDuo's tip window. Epoch 2 is absent after +// seeding around tip FirstRoad(4) ({3,4} plus genesis {0,1}). QCs are +// consecutive: the first unverifiable entry ends restore (tip after the hole +// is discarded). +func TestNewState_BreaksOnUnverifiableWALQC(t *testing.T) { + rng := utils.TestRng() + buildReg, keys := epoch.GenRegistryAt(rng, 3, 0) + buildReg.EnsureEpoch(2) + buildReg.EnsureEpoch(4) + ep0 := utils.OrPanic1(buildReg.EpochAt(0)) + ep2 := utils.OrPanic1(buildReg.EpochAt(epoch.FirstRoad(2))) + ep4 := utils.OrPanic1(buildReg.EpochAt(epoch.FirstRoad(4))) + + qc0, blocks0 := TestCommitQC(rng, ep0, keys, utils.None[*types.CommitQC]()) + gr0 := qc0.QC().GlobalRange() + qcGap := types.FullCommitQCAt(ep2, keys, epoch.FirstRoad(2), gr0.Next) + qcTip := types.FullCommitQCAt(ep4, keys, epoch.FirstRoad(4), qcGap.QC().GlobalRange().Next) + + dir := t.TempDir() + dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), buildReg.FirstBlock())) + require.NoError(t, dw1.CommitQCs.PersistQC(qc0)) + require.NoError(t, dw1.CommitQCs.PersistQC(qcGap)) + require.NoError(t, dw1.CommitQCs.PersistQC(qcTip)) + for i, n := 0, gr0.First; n < gr0.Next; n++ { + require.NoError(t, dw1.Blocks.PersistBlock(n, blocks0[i])) + i++ + } + require.NoError(t, dw1.Close()) + + // Drop epochs ≥2 so NewState's SetupInitialDuo(tip=FirstRoad(4)) re-seeds + // only {3,4} on top of genesis {0,1}; epoch 2 stays missing → qcGap hole. + buildReg.DropEpochsFromForTest(2) + + dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), buildReg.FirstBlock())) + state, err := NewState(&Config{Registry: buildReg}, dw2) + require.NoError(t, err, "unverifiable WAL entries must be discarded, not fail NewState") + + // Trusted prefix (epoch 0) kept; hole ends restore (tip discarded). + require.Equal(t, gr0.Next, state.NextBlock()) + for n := gr0.First; n < gr0.Next; n++ { + got, err := state.TryBlock(n) + require.NoError(t, err) + require.NotNil(t, got) + } + require.NoError(t, dw2.Close()) +} diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 7e0229b0d8..622eaaf34f 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -63,3 +63,25 @@ func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumb } return registry } + +// DropEpochsFromForTest removes epochs >= from. Test-only: simulates a registry +// that never learned committees for those epochs (e.g. before state sync). +func (r *Registry) DropEpochsFromForTest(from types.EpochIndex) { + for s := range r.state.Lock() { + for idx := range s.m { + if idx >= from { + delete(s.m, idx) + } + } + if from == 0 { + s.latest = 0 + } else if s.latest >= from { + s.latest = from - 1 + } + } + if from == 0 { + r.highestEpoch.Store(0) + } else if r.highestEpoch.Load() >= from { + r.highestEpoch.Store(from - 1) + } +} From 0db0b60831cc5858a967e841b571292b509a0186 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 19:48:22 -0700 Subject: [PATCH 71/98] fix(autobahn): catch up consensus CommitQCs road-by-road from avail tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LastCommitQC Iter coalesces; apply View→tip via avail.CommitQC so epoch boundaries are not skipped and Current-only verify stays valid. Co-authored-by: Cursor --- .../internal/autobahn/consensus/inner.go | 8 ++--- .../internal/autobahn/consensus/state.go | 34 +++++++++++++++---- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 3a73b51a7c..60c0a35f9a 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -156,11 +156,9 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if qc.Proposal().Index() < i.View().Index { return nil } - // Sequential guarantee: avail only advances LastCommitQC after accepting - // CommitQCs in road order, and consensus advances View from that tip. - // So a non-stale QC's EpochIndex must always match the current View's - // epoch (i.epochs.Current). Mismatch is a sequencing bug — hard-error; do not - // re-resolve via registry. + // Callers must apply CommitQCs in road order (see catchUpCommitQCs). Then a + // non-stale QC's EpochIndex matches i.epochs.Current; mismatch is a bug — + // hard-error, do not re-resolve via registry. if got, want := qc.Proposal().EpochIndex(), i.epochs.Current.EpochIndex(); got != want { return fmt.Errorf("CommitQC epoch %d != View epoch %d (road %d)", got, want, qc.Proposal().Index()) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 3985f1d0e7..e49d3b03ad 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -234,6 +234,26 @@ func (s *State) PushTimeoutVote(vote *types.FullTimeoutVote) error { func (s *State) Data() *data.State { return s.avail.Data() } func (s *State) Avail() *avail.State { return s.avail } +// catchUpCommitQCs applies avail CommitQCs from the current View through tip +// inclusive, in road order. LastCommitQC may coalesce past an epoch boundary; +// walking road-by-road keeps pushCommitQC's Current-epoch invariant. Avail +// admits CommitQCs contiguously, so there is no hole between View and tip. +func (s *State) catchUpCommitQCs(ctx context.Context, tip types.RoadIndex) error { + for { + next := s.innerRecv.Load().View().Index + if next > tip { + return nil + } + qc, err := s.avail.CommitQC(ctx, next) + if err != nil { + return err + } + if err := s.pushCommitQC(qc); err != nil { + return err + } + } +} + // Constructs new proposals. func (s *State) runPropose(ctx context.Context) error { return s.myView.Iter(ctx, func(ctx context.Context, vs types.ViewSpec) error { @@ -330,14 +350,16 @@ func (s *State) Run(ctx context.Context) error { }) }) scope.SpawnNamed("pushCommitQC", func() error { - // We pull the CommitQC back from "avail" for dissemination. This ensures - // that we only push CommitQCs that have been successfully "logged" and - // sequenced by the availability layer. + // Pull CommitQCs back from avail for dissemination (only after they + // are logged/sequenced). LastCommitQC is a coalescing tip watch — it + // can skip intermediate roads — so catch up View→tip road-by-road + // (including epoch boundaries) rather than applying the tip alone. return s.avail.LastCommitQC().Iter(ctx, func(ctx context.Context, last utils.Option[*types.CommitQC]) error { - if qc, ok := last.Get(); ok { - return s.pushCommitQC(qc) + tip, ok := last.Get() + if !ok { + return nil } - return nil + return s.catchUpCommitQCs(ctx, tip.Proposal().Index()) }) }) scope.SpawnNamed("pushPrepareQC", func() error { From 3f7edd50a0eaecb0116ae92d869cbc21d49f00c1 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 20:20:17 -0700 Subject: [PATCH 72/98] refactor(autobahn): trust avail CommitQC tip in consensus without re-verify Avail already verifies at admit; consensus only adopts the tip and aligns its epoch duo via DuoAt when the tipcut leaves Current. Co-authored-by: Cursor --- .../internal/autobahn/consensus/inner.go | 33 +++++------------- .../internal/autobahn/consensus/state.go | 34 ++++--------------- 2 files changed, 15 insertions(+), 52 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 60c0a35f9a..552aed8de1 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -152,41 +152,26 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( } func (s *State) pushCommitQC(qc *types.CommitQC) error { - i := s.innerRecv.Load() - if qc.Proposal().Index() < i.View().Index { - return nil - } - // Callers must apply CommitQCs in road order (see catchUpCommitQCs). Then a - // non-stale QC's EpochIndex matches i.epochs.Current; mismatch is a bug — - // hard-error, do not re-resolve via registry. - if got, want := qc.Proposal().EpochIndex(), i.epochs.Current.EpochIndex(); got != want { - return fmt.Errorf("CommitQC epoch %d != View epoch %d (road %d)", - got, want, qc.Proposal().Index()) - } - if err := qc.Verify(i.epochs.Current); err != nil { - return fmt.Errorf("qc.Verify(): %w", err) - } + // Trust avail's tip: PushCommitQC already verified at admit. Consensus only + // adopts View and keeps epochs aligned with the tipcut (Index+1). for iSend := range s.inner.Lock() { i := iSend.Load() if qc.Proposal().Index() < i.View().Index { return nil } - // Epoch for the next view. Within an epoch this is Current; on the - // last CommitQC of the epoch it is an explicit transition, resolved - // from the registry held by State (inner holds no registry). nextRoad := qc.Proposal().Index() + 1 nextDuo := i.epochs if !i.epochs.Current.RoadRange().Has(nextRoad) { - // Invariant: N+1 is registered before this CommitQC (setup / - // AdvanceIfNeeded). Hard-error if missing — do not WaitForDuo - // like avail/data do. - nextEp, err := s.registry.EpochAt(nextRoad) + // Boundary or coalesced tip past Current: open duo at tipcut. + // Invariant: seeded before this tip (SetupInitialDuo / AdvanceIfNeeded). + // Hard-error if missing — do not WaitForDuo like avail/data. + duo, err := s.registry.DuoAt(nextRoad) if err != nil { - logger.Error("next epoch not in registry at CommitQC boundary", + logger.Error("tipcut duo not in registry after avail CommitQC tip", "road", nextRoad) - return fmt.Errorf("EpochAt(%d): %w", nextRoad, err) + return fmt.Errorf("DuoAt(%d): %w", nextRoad, err) } - nextDuo = types.EpochDuo{Prev: utils.Some(i.epochs.Current), Current: nextEp} + nextDuo = duo } iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, epochs: nextDuo}) } diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index e49d3b03ad..2f5d0670ae 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -234,26 +234,6 @@ func (s *State) PushTimeoutVote(vote *types.FullTimeoutVote) error { func (s *State) Data() *data.State { return s.avail.Data() } func (s *State) Avail() *avail.State { return s.avail } -// catchUpCommitQCs applies avail CommitQCs from the current View through tip -// inclusive, in road order. LastCommitQC may coalesce past an epoch boundary; -// walking road-by-road keeps pushCommitQC's Current-epoch invariant. Avail -// admits CommitQCs contiguously, so there is no hole between View and tip. -func (s *State) catchUpCommitQCs(ctx context.Context, tip types.RoadIndex) error { - for { - next := s.innerRecv.Load().View().Index - if next > tip { - return nil - } - qc, err := s.avail.CommitQC(ctx, next) - if err != nil { - return err - } - if err := s.pushCommitQC(qc); err != nil { - return err - } - } -} - // Constructs new proposals. func (s *State) runPropose(ctx context.Context) error { return s.myView.Iter(ctx, func(ctx context.Context, vs types.ViewSpec) error { @@ -350,16 +330,14 @@ func (s *State) Run(ctx context.Context) error { }) }) scope.SpawnNamed("pushCommitQC", func() error { - // Pull CommitQCs back from avail for dissemination (only after they - // are logged/sequenced). LastCommitQC is a coalescing tip watch — it - // can skip intermediate roads — so catch up View→tip road-by-road - // (including epoch boundaries) rather than applying the tip alone. + // Pull the CommitQC tip back from avail after it has been logged and + // verified at admit. Tip watch may coalesce; pushCommitQC aligns the + // epoch duo to the tipcut without re-verifying or replaying roads. return s.avail.LastCommitQC().Iter(ctx, func(ctx context.Context, last utils.Option[*types.CommitQC]) error { - tip, ok := last.Get() - if !ok { - return nil + if qc, ok := last.Get(); ok { + return s.pushCommitQC(qc) } - return s.catchUpCommitQCs(ctx, tip.Proposal().Index()) + return nil }) }) scope.SpawnNamed("pushPrepareQC", func() error { From a05f2dac10bac01f10f43df07ed641c2c32c757c Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 20:41:50 -0700 Subject: [PATCH 73/98] Revert "fix(autobahn): discard unverifiable data WAL QCs on restart" This reverts commit f16bb0e6e0aaa8e83390e37764d5afb879fb56ef. --- sei-tendermint/autobahn/types/testonly.go | 16 ------ .../internal/autobahn/data/state.go | 49 +++---------------- .../internal/autobahn/data/state_test.go | 48 ------------------ .../internal/autobahn/epoch/testonly.go | 22 --------- 4 files changed, 8 insertions(+), 127 deletions(-) diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index cffc89e42e..151e8f960f 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -311,22 +311,6 @@ func ProposalAt(ep *Epoch, view View) *Proposal { return newProposal(view, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) } -// FullCommitQCAt builds a signed FullCommitQC at an explicit road covering a -// single global block at globalFirst (one synthetic lane header). For WAL / -// epoch-admission tests that need contiguous GlobalRanges at sparse roads. -func FullCommitQCAt(ep *Epoch, keys []SecretKey, road RoadIndex, globalFirst GlobalBlockNumber) *FullCommitQC { - lane := ep.Committee().Lanes().At(0) - payload := utils.OrPanic1(PayloadBuilder{CreatedAt: time.Time{}}.Build()) - header := NewBlock(lane, 0, BlockHeaderHash{}, payload).Header() - lr := NewLaneRange(lane, 0, utils.Some(header)) - p := newProposal(View{EpochIndex: ep.EpochIndex(), Index: road}, time.Time{}, []*LaneRange{lr}, utils.None[*AppProposal](), globalFirst) - vs := make([]*Signed[*CommitVote], len(keys)) - for i, k := range keys { - vs[i] = Sign(k, NewCommitVote(p)) - } - return NewFullCommitQC(NewCommitQC(vs), []*BlockHeader{header}) -} - // GenProposalForEpoch generates a Proposal at a specific view whose epochIndex, // firstBlock, and lane IDs are all consistent with ep. Use in tests that verify // QCs against a known Epoch. diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index e415515694..bd647ed905 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log/slog" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" @@ -13,11 +12,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" - "github.com/sei-protocol/seilog" ) -var logger = seilog.NewLogger("tendermint", "internal", "autobahn", "data") - const blocksCacheSize = 4000 // ErrNotFound is returned when the resource is not found. @@ -328,26 +324,12 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { } lastExecuted := roadForGlobal(loadedQCs, cfg.LastExecutedBlock) cfg.Registry.SetupInitialDuo(lastExecuted, lastQC) - // Restore only a contiguous WAL QC prefix we can verify against the seeded - // epoch window. QCs are consecutive: the first unverifiable entry ends - // restore — nothing after it is kept (that would skip a hole). - // - // SetupInitialDuo opens Prev|Current around the CommitQC tip (plus limited - // lookahead). Older epochs are intentionally not registered. Today that is - // mostly a retention/prune invariant; in the future committees come from - // execution — if we cannot execute epoch N we do not have the committee for - // epoch N+2. Discard unverifiable entries (warn loudly) rather than invent a - // committee. The only way to recover past that gap is state sync from a - // trusted source. + // Restore QCs. insertQC handles partially pruned QCs (range starts + // before inner.first) by skipping the pruned prefix. for _, qc := range loadedQCs { - road := qc.QC().Proposal().Index() - ep, err := cfg.Registry.EpochAt(road) + ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) if err != nil { - logger.Error("discarding unverifiable WAL CommitQCs from here (epoch not registered; consecutive QCs required; state sync to recover further)", - slog.Uint64("road", uint64(road)), - slog.Uint64("kept_next_qc", uint64(inner.nextQC)), - slog.String("err", err.Error())) - break + return nil, fmt.Errorf("load QC from WAL: epoch lookup: %w", err) } if err := inner.insertQC(qc, ep); err != nil { return nil, fmt.Errorf("load QC from WAL: %w", err) @@ -357,7 +339,7 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { expectedBlock := inner.first for _, lb := range dataWAL.Blocks.ConsumeLoaded() { if lb.Number < inner.first || lb.Number >= inner.nextQC { - continue // outside QC range (stale from reconcile / discarded QCs) + continue // outside QC range (stale from reconcile) } if lb.Number != expectedBlock { return nil, fmt.Errorf("block gap in WAL: expected %d, got %d", expectedBlock, lb.Number) @@ -366,12 +348,7 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { qc := inner.qcs[lb.Number] ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) if err != nil { - // QC was admitted above; missing epoch here is unexpected — stop - // contiguous block restore and keep cursors at the last good block. - logger.Error("discarding WAL blocks after trusted prefix (epoch not registered)", - slog.Uint64("block", uint64(lb.Number)), - slog.String("err", err.Error())) - break + return nil, fmt.Errorf("load block %d from WAL: epoch lookup: %w", lb.Number, err) } if err := lb.Block.Verify(ep.Committee()); err != nil { return nil, fmt.Errorf("load block %d from WAL: verify: %w", lb.Number, err) @@ -386,18 +363,8 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { } // Data loaded from WALs was already persisted in the previous run. inner.nextBlockToPersist = inner.nextBlock - // Truncate disk to the trusted restore window so the next restart does not - // reload discarded QCs/blocks, and blocks cursor stays in sequence. - if inner.first > cfg.Registry.FirstBlock() { - if err := dataWAL.TruncateBefore(inner.first); err != nil { - return nil, fmt.Errorf("truncate WAL to trusted restore window: %w", err) - } - } - if dataWAL.Blocks.Next() < inner.nextBlock { - if err := dataWAL.Blocks.TruncateBefore(inner.nextBlock); err != nil { - return nil, fmt.Errorf("advance blocks WAL cursor to %d: %w", inner.nextBlock, err) - } - } + // WAL cursor consistency was resolved by DataWAL.reconcile at construction. + // Verify the blocks persister cursor is not behind inner.nextBlock. if dataWAL.Blocks.Next() < inner.nextBlock { return nil, fmt.Errorf("blocks WAL cursor %d behind inner.nextBlock %d after reconciliation", dataWAL.Blocks.Next(), inner.nextBlock) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 79da943ce2..c834463f89 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -1164,51 +1164,3 @@ func TestPushQC_AdvancesEpochDuoAtBoundary(t *testing.T) { } }) } - -// TestNewState_BreaksOnUnverifiableWALQC covers restart when the CommitQC WAL -// hits an epoch outside SetupInitialDuo's tip window. Epoch 2 is absent after -// seeding around tip FirstRoad(4) ({3,4} plus genesis {0,1}). QCs are -// consecutive: the first unverifiable entry ends restore (tip after the hole -// is discarded). -func TestNewState_BreaksOnUnverifiableWALQC(t *testing.T) { - rng := utils.TestRng() - buildReg, keys := epoch.GenRegistryAt(rng, 3, 0) - buildReg.EnsureEpoch(2) - buildReg.EnsureEpoch(4) - ep0 := utils.OrPanic1(buildReg.EpochAt(0)) - ep2 := utils.OrPanic1(buildReg.EpochAt(epoch.FirstRoad(2))) - ep4 := utils.OrPanic1(buildReg.EpochAt(epoch.FirstRoad(4))) - - qc0, blocks0 := TestCommitQC(rng, ep0, keys, utils.None[*types.CommitQC]()) - gr0 := qc0.QC().GlobalRange() - qcGap := types.FullCommitQCAt(ep2, keys, epoch.FirstRoad(2), gr0.Next) - qcTip := types.FullCommitQCAt(ep4, keys, epoch.FirstRoad(4), qcGap.QC().GlobalRange().Next) - - dir := t.TempDir() - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), buildReg.FirstBlock())) - require.NoError(t, dw1.CommitQCs.PersistQC(qc0)) - require.NoError(t, dw1.CommitQCs.PersistQC(qcGap)) - require.NoError(t, dw1.CommitQCs.PersistQC(qcTip)) - for i, n := 0, gr0.First; n < gr0.Next; n++ { - require.NoError(t, dw1.Blocks.PersistBlock(n, blocks0[i])) - i++ - } - require.NoError(t, dw1.Close()) - - // Drop epochs ≥2 so NewState's SetupInitialDuo(tip=FirstRoad(4)) re-seeds - // only {3,4} on top of genesis {0,1}; epoch 2 stays missing → qcGap hole. - buildReg.DropEpochsFromForTest(2) - - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), buildReg.FirstBlock())) - state, err := NewState(&Config{Registry: buildReg}, dw2) - require.NoError(t, err, "unverifiable WAL entries must be discarded, not fail NewState") - - // Trusted prefix (epoch 0) kept; hole ends restore (tip discarded). - require.Equal(t, gr0.Next, state.NextBlock()) - for n := gr0.First; n < gr0.Next; n++ { - got, err := state.TryBlock(n) - require.NoError(t, err) - require.NotNil(t, got) - } - require.NoError(t, dw2.Close()) -} diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 622eaaf34f..7e0229b0d8 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -63,25 +63,3 @@ func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumb } return registry } - -// DropEpochsFromForTest removes epochs >= from. Test-only: simulates a registry -// that never learned committees for those epochs (e.g. before state sync). -func (r *Registry) DropEpochsFromForTest(from types.EpochIndex) { - for s := range r.state.Lock() { - for idx := range s.m { - if idx >= from { - delete(s.m, idx) - } - } - if from == 0 { - s.latest = 0 - } else if s.latest >= from { - s.latest = from - 1 - } - } - if from == 0 { - r.highestEpoch.Store(0) - } else if r.highestEpoch.Load() >= from { - r.highestEpoch.Store(from - 1) - } -} From 424d5d68db506115cb8a7201f59508a2d6255005 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 20:56:53 -0700 Subject: [PATCH 74/98] fix(autobahn): range-check AppQC road against its epoch Defense-in-depth: EpochForIndex alone trusts the self-declared epoch; reject when road_index falls outside that epoch's RoadRange. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 5c4b6d9f36..f24ce00d8d 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -502,6 +502,10 @@ func (m *FullProposal) Verify(vs ViewSpec) error { if err != nil { return fmt.Errorf("app epoch_index %d: %w", app.EpochIndex(), err) } + if rr := appEp.RoadRange(); !rr.Has(app.RoadIndex()) { + return fmt.Errorf("app road_index %v not in epoch %d roads [%v,%v)", + app.RoadIndex(), app.EpochIndex(), rr.First, rr.Next) + } appQC, ok := m.appQC.Get() if !ok { return errors.New("appQC missing") From b759b0e1f0446f32ad2728b69db804d0d5293654 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 21:19:24 -0700 Subject: [PATCH 75/98] refactor(autobahn): seed restart epochs across loaded CommitQC WAL span SetupInitialDuo takes Option[CommitQCSpan] and registers every epoch from firstLoaded through tip, standing in for reading epoch info from blocks. Co-authored-by: Cursor --- .../internal/autobahn/avail/state_test.go | 17 +++--- .../internal/autobahn/data/state.go | 12 +++-- .../internal/autobahn/epoch/registry.go | 37 ++++++++----- .../internal/autobahn/epoch/registry_test.go | 52 +++++++++++++------ 4 files changed, 78 insertions(+), 40 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 0eed49748c..3b9fa5592d 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -24,12 +24,14 @@ var ( noCommitQCCB = utils.None[func(*types.CommitQC)]() ) -// registerDuoAtEpoch seeds the registry (CommitQC tip in epoch n → {n-1, n}) -// and installs Prev=n-1|Current=n as the state's operating window. +// registerDuoAtEpoch seeds the registry (CommitQC tip in epoch n → {n-1, n} +// via tipcut EnsureDuoAt) and installs Prev=n-1|Current=n as the state's +// operating window. func registerDuoAtEpoch(s *State, n types.EpochIndex) { r := s.data.Registry() - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(epoch.FirstRoad(n))) - duo := utils.OrPanic1(r.DuoAt(epoch.FirstRoad(n))) + tip := epoch.FirstRoad(n) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(epoch.CommitQCSpan{First: tip, Last: tip})) + duo := utils.OrPanic1(r.DuoAt(tip)) for inner := range s.inner.Lock() { inner.epochDuo.Store(duo) } @@ -1095,10 +1097,11 @@ func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { _, err := registry.DuoAt(tip2) require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") - // CommitQC closing epoch 1 opens {0,1}; closing-road execution adds 2 and 3. + // CommitQC closing epoch 1 opens {1} then tipcut/execution add 2 and 3. + tip1 := epoch.LastRoad(1) registry.SetupInitialDuo( - utils.Some(epoch.LastRoad(1)), - utils.Some(epoch.LastRoad(1)), + utils.Some(tip1), + utils.Some(epoch.CommitQCSpan{First: tip1, Last: tip1}), ) tipDuo2, err := registry.DuoAt(tip2) require.NoError(t, err) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index bd647ed905..d5e6cefeb7 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -309,7 +309,8 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { inner.skipTo(dataFirst) } // Seed epochs once here (avail/consensus do not call SetupInitialDuo). - // CommitQC tip is the authoritative restart window (EnsureDuoAt tipcut). + // Seed {firstLoaded..tip} so every retained CommitQC has an epoch — stand-in + // for reading committee/epoch info from blocks once that is on-chain. // Execution tip only adds AdvanceIfNeeded lookahead via EnsureAfterExecuted; // if LastExecutedBlock's QC was pruned from the WAL, roadForGlobal is None // and that lookahead is skipped — live AdvanceIfNeeded re-seeds forward. @@ -318,12 +319,15 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { // TODO(autobahn): LastExecutedBlock comes from app.LastBlockHeight() (app // state DB). Move execution tip into Giga storage; stop reading the app DB. loadedQCs := dataWAL.CommitQCs.ConsumeLoaded() - lastQC := utils.None[types.RoadIndex]() + commitQCs := utils.None[epoch.CommitQCSpan]() if n := len(loadedQCs); n > 0 { - lastQC = utils.Some(loadedQCs[n-1].QC().Proposal().Index()) + commitQCs = utils.Some(epoch.CommitQCSpan{ + First: loadedQCs[0].QC().Proposal().Index(), + Last: loadedQCs[n-1].QC().Proposal().Index(), + }) } lastExecuted := roadForGlobal(loadedQCs, cfg.LastExecutedBlock) - cfg.Registry.SetupInitialDuo(lastExecuted, lastQC) + cfg.Registry.SetupInitialDuo(lastExecuted, commitQCs) // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. for _, qc := range loadedQCs { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 19c0271120..f5632b8291 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -63,15 +63,23 @@ func NewRegistry( } // TODO: in the future this information will be read from disk and verified // (snapshots / state sync); until then seed a genesis placeholder. - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadIndex]()) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[CommitQCSpan]()) return r, nil } +// CommitQCSpan is the inclusive first/last CommitQC proposal roads loaded from +// the data WAL. Both ends are always set together (single QC ⇒ First == Last). +type CommitQCSpan struct { + First, Last types.RoadIndex +} + // SetupInitialDuo seeds placeholder epochs on restart. Called only from // data.NewState (avail/consensus rely on that window; their tips must not lead // data across an unseeded epoch). // -// 1. Open Prev|Current from lastCommitQC (or execution-only / fresh {0,1}). +// 1. Seed every epoch from commitQCs.First through commitQCs.Last (the loaded +// WAL span). Stand-in for reading committee/epoch info from retained blocks; +// today placeholders reuse the genesis committee. // 2. EnsureDuoAt(commit tipcut) so data/avail DuoAt(Index+1) works — including // when the tip closes epoch N (tipcut needs N+1 even if execution lags). // 3. EnsureAfterExecuted(lastExecuted) restores live AdvanceIfNeeded lookahead. @@ -82,26 +90,29 @@ func NewRegistry( // TODO(autobahn): lastExecutedRoad is derived from app.LastBlockHeight() (Cosmos // app state DB). Do not keep depending on the app DB for execution tip / epoch // seeding — persist that in Giga storage alongside CommitQC/AppQC WALs. -func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[types.RoadIndex]) { +// TODO(autobahn): replace genesis placeholders with epoch info carried on blocks. +func (r *Registry) SetupInitialDuo(lastExecutedRoad utils.Option[types.RoadIndex], commitQCs utils.Option[CommitQCSpan]) { var windowFirst, windowLast types.EpochIndex haveWindow := false executedForAdvance := utils.None[types.RoadIndex]() - if road, ok := lastCommitQC.Get(); ok { - tipEpoch := IndexForRoad(road) - windowFirst = 0 - if tipEpoch >= 1 { - windowFirst = tipEpoch - 1 + if span, ok := commitQCs.Get(); ok { + windowFirst = IndexForRoad(span.First) + windowLast = IndexForRoad(span.Last) + if windowFirst > windowLast { + logger.Warn("first CommitQC epoch past tip on restart; clamping to tip", + slog.Uint64("first_road", uint64(span.First)), + slog.Uint64("tip_road", uint64(span.Last))) + windowFirst = windowLast } - windowLast = tipEpoch haveWindow = true } if road, ok := lastExecutedRoad.Get(); ok { - if commitRoad, cok := lastCommitQC.Get(); cok && road > commitRoad { + if span, cok := commitQCs.Get(); cok && road > span.Last { logger.Warn("execution tip past CommitQC tip on restart; ignoring executed tip for epoch seeding", slog.Uint64("executed_road", uint64(road)), - slog.Uint64("commit_qc_road", uint64(commitRoad))) + slog.Uint64("commit_qc_road", uint64(span.Last))) } else { tipEpoch := IndexForRoad(road) if !haveWindow { @@ -130,8 +141,8 @@ func (r *Registry) SetupInitialDuo(lastExecutedRoad, lastCommitQC utils.Option[t } } - if road, ok := lastCommitQC.Get(); ok { - r.EnsureDuoAt(road + 1) // operating tipcut after last CommitQC + if span, ok := commitQCs.Get(); ok { + r.EnsureDuoAt(span.Last + 1) // operating tipcut after last CommitQC } if road, ok := executedForAdvance.Get(); ok { r.EnsureAfterExecuted(road) diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index c299530790..94a2eaabc1 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -86,8 +86,9 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { r, _ := makeRegistry(t) - // CommitQC mid-epoch 5 → {4,5} only; does not claim AdvanceIfNeeded. - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(midRoad(5))) + // Single tip mid-5 + tipcut EnsureDuoAt → {4,5}. + tip := midRoad(5) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(CommitQCSpan{First: tip, Last: tip})) for _, idx := range []types.EpochIndex{4, 5} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after CommitQC seeding: %v", idx, err) @@ -100,9 +101,10 @@ func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { r, _ := makeRegistry(t) - // Closing CommitQC tipcut is FirstRoad(6); EnsureDuoAt restores epoch 6. - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(LastRoad(5))) - for _, idx := range []types.EpochIndex{4, 5, 6} { + // Closing tip: {5} + tipcut FirstRoad(6) → {5,6}. + tip := LastRoad(5) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(CommitQCSpan{First: tip, Last: tip})) + for _, idx := range []types.EpochIndex{5, 6} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after closing CommitQC: %v", idx, err) } @@ -115,10 +117,25 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { } } +func TestSetupInitialDuo_WALSpan(t *testing.T) { + r, _ := makeRegistry(t) + // firstLoaded in epoch 2, tip in epoch 5 → seed {2,3,4,5}; tipcut stays in 5. + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(CommitQCSpan{First: midRoad(2), Last: midRoad(5)})) + for _, idx := range []types.EpochIndex{2, 3, 4, 5} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after WAL-span seeding: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(6)); err == nil { + t.Fatal("EpochAt(epoch 6) should not be present from mid-epoch WAL span") + } +} + func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { r, _ := makeRegistry(t) - // CommitQC in epoch 5 opens {4,5}; mid-5 EnsureAfterExecuted → epoch 6. - r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(midRoad(5))) + tip := midRoad(5) + // Tip mid-5 + tipcut → {4,5}; mid-5 EnsureAfterExecuted → epoch 6. + r.SetupInitialDuo(utils.Some(tip), utils.Some(CommitQCSpan{First: tip, Last: tip})) for _, idx := range []types.EpochIndex{4, 5, 6} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after execution extend: %v", idx, err) @@ -131,9 +148,10 @@ func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { r, _ := makeRegistry(t) - // Closing execution: EnsureAfterExecuted → 6 and 7. - r.SetupInitialDuo(utils.Some(LastRoad(5)), utils.Some(LastRoad(5))) - for _, idx := range []types.EpochIndex{4, 5, 6, 7} { + tip := LastRoad(5) + // Closing execution: EnsureAfterExecuted → 6 and 7; tipcut adds 6. + r.SetupInitialDuo(utils.Some(tip), utils.Some(CommitQCSpan{First: tip, Last: tip})) + for _, idx := range []types.EpochIndex{5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after closing execution: %v", idx, err) } @@ -145,8 +163,9 @@ func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { func TestSetupInitialDuo_ExecutionPastCommitQCIgnored(t *testing.T) { r, _ := makeRegistry(t) - // CommitQC mid-3 → {2,3}; execution mid-5 is past CommitQC → warn, ignore. - r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(midRoad(3))) + tip := midRoad(3) + // CommitQC mid-3 → {3}+tipcut {2,3}; execution mid-5 past tip → warn, ignore. + r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(CommitQCSpan{First: tip, Last: tip})) for _, idx := range []types.EpochIndex{2, 3} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) from CommitQC: %v", idx, err) @@ -159,9 +178,9 @@ func TestSetupInitialDuo_ExecutionPastCommitQCIgnored(t *testing.T) { func TestSetupInitialDuo_ExecutionDoesNotShrinkCommitWindow(t *testing.T) { r, _ := makeRegistry(t) - // CommitQC in epoch 5 → {4,5}; lagging execution in epoch 3 must not drop 4/5. - r.SetupInitialDuo(utils.Some(midRoad(3)), utils.Some(midRoad(5))) - for _, idx := range []types.EpochIndex{4, 5} { + // WAL span {2..5}; lagging execution mid-3 must not drop 4/5. + r.SetupInitialDuo(utils.Some(midRoad(3)), utils.Some(CommitQCSpan{First: midRoad(2), Last: midRoad(5)})) + for _, idx := range []types.EpochIndex{2, 3, 4, 5} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) must remain after lagging execution: %v", idx, err) } @@ -188,7 +207,8 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { func TestDuoAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(midRoad(2))) + tip := midRoad(2) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(CommitQCSpan{First: tip, Last: tip})) duo, err := r.DuoAt(FirstRoad(2)) if err != nil { t.Fatalf("DuoAt(epoch 2) error: %v", err) From 53a81b8b59b453eadafd73d1df40e48c2ec6f79f Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 17 Jul 2026 22:10:57 -0700 Subject: [PATCH 76/98] fix(autobahn): re-verify CommitQCs on avail reload and consensus tip Restore signature checks at avail WAL load and in pushCommitQC (EpochAt hard-fail if missing), matching main's defense-in-depth on the tip path. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 27 ++++- .../internal/autobahn/avail/inner_test.go | 114 +++++++++--------- .../internal/autobahn/avail/state.go | 3 +- .../internal/autobahn/avail/state_test.go | 5 +- .../internal/autobahn/consensus/inner.go | 15 ++- 5 files changed, 99 insertions(+), 65 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 0997d53ce4..de07e3ef5a 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -7,6 +7,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail/metrics" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) @@ -75,7 +76,7 @@ func (ls *loadedAvailState) nextCommitQC() types.RoadIndex { return tip } -func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailState]) (*inner, error) { +func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailState]) (*inner, error) { lanes := map[types.LaneID]*laneState{} for lane := range startEpochDuo.Current.Committee().Lanes().All() { lanes[lane] = newLaneState() @@ -107,6 +108,9 @@ func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailStat slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), slog.Uint64("globalNumber", uint64(anchor.AppQC.Proposal().GlobalNumber())), ) + if err := verifyLoadedCommitQC(registry, anchor.CommitQC); err != nil { + return nil, fmt.Errorf("load prune-anchor CommitQC: %w", err) + } if _, err := i.prune(anchor.AppQC, anchor.CommitQC); err != nil { return nil, fmt.Errorf("prune: %w", err) } @@ -127,7 +131,10 @@ func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailStat } // Restore persisted CommitQCs. Tipcut insert above may already hold the - // anchor; skip entries below commitQCs.next. + // anchor; skip entries below commitQCs.next. Re-verify each QC so the tip + // published to consensus is signature-checked (same as live PushCommitQC). + // Epoch must already be seeded (data.SetupInitialDuo); missing epoch is a + // hard error — avail tip must not lead past an unseeded epoch. for _, lqc := range l.commitQCs { if lqc.Index < i.commitQCs.next { continue @@ -135,6 +142,9 @@ func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailStat if lqc.Index != i.commitQCs.next { return nil, fmt.Errorf("non-contiguous persisted commitQCs: expected %d, got %d", i.commitQCs.next, lqc.Index) } + if err := verifyLoadedCommitQC(registry, lqc.QC); err != nil { + return nil, fmt.Errorf("load CommitQC %d: %w", lqc.Index, err) + } i.commitQCs.pushBack(lqc.QC) } if i.commitQCs.next > i.commitQCs.first { @@ -174,6 +184,19 @@ func newInner(startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailStat return i, nil } +// verifyLoadedCommitQC resolves the QC's epoch from the registry and verifies +// signatures. Hard-errors if the epoch is not registered. +func verifyLoadedCommitQC(registry *epoch.Registry, qc *types.CommitQC) error { + ep, err := registry.EpochAt(qc.Proposal().Index()) + if err != nil { + return fmt.Errorf("epoch lookup: %w", err) + } + if err := qc.Verify(ep); err != nil { + return fmt.Errorf("verify: %w", err) + } + return nil +} + func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { return i.lanes[lane].votes.q[n].laneQC() } diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index d282d51d41..60b223e11b 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -14,7 +14,7 @@ import ( func TestPruneMismatchedIndices(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) ep, err := registry.EpochAt(0) require.NoError(t, err) @@ -66,9 +66,9 @@ func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber func TestNewInnerFreshStart(t *testing.T) { rng := utils.TestRng() - registry, _, _ := epoch.GenRegistry(rng, 4) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.None[*loadedAvailState]()) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.None[*loadedAvailState]()) require.NoError(t, err) require.False(t, i.latestAppQC.IsPresent()) @@ -87,7 +87,7 @@ func TestNewInnerFreshStart(t *testing.T) { func TestDecodePruneAnchorIncomplete(t *testing.T) { rng := utils.TestRng() - _, keys, _ := epoch.GenRegistry(rng, 4) + _, keys := epoch.GenRegistryAt(rng, 4, 0) appProposal := types.NewAppProposal(42, 5, types.GenAppHash(rng), 0) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) @@ -101,11 +101,11 @@ func TestDecodePruneAnchorIncomplete(t *testing.T) { func TestNewInnerLoadedNoAnchor(t *testing.T) { rng := utils.TestRng() - registry, _, _ := epoch.GenRegistry(rng, 4) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) loaded := &loadedAvailState{} - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // No anchor loaded, app votes should start at the registry's first block. @@ -116,20 +116,20 @@ func TestNewInnerLoadedNoAnchor(t *testing.T) { func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() - registry, _, _ := epoch.GenRegistry(rng, 4) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) // NewRegistry already seeds epoch 1. duo := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(1))) require.Equal(t, types.EpochIndex(1), duo.Current.EpochIndex()) - _, err := newInner(duo, utils.Some(&loadedAvailState{})) + _, err := newInner(registry, duo, utils.Some(&loadedAvailState{})) require.Error(t, err) - _, err = newInner(duo, utils.None[*loadedAvailState]()) + _, err = newInner(registry, duo, utils.None[*loadedAvailState]()) require.Error(t, err) } func TestNewInnerLoadedBlocksContiguous(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // Build 3 contiguous blocks: 0, 1, 2. @@ -145,7 +145,7 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) q := i.lanes[lane].blocks @@ -167,14 +167,14 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() loaded := &loadedAvailState{ blocks: map[types.LaneID][]persist.LoadedBlock{lane: {}}, } - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) q := i.lanes[lane].blocks @@ -184,7 +184,7 @@ func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) unknownKey := types.GenSecretKey(rng) unknownLane := unknownKey.Public() @@ -194,7 +194,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{unknownLane: {{Number: 0, Proposal: b}}}, } - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) for lane := range registry.LatestEpoch().Committee().Lanes().All() { @@ -207,7 +207,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane0 := keys[0].Public() lane1 := keys[1].Public() @@ -231,7 +231,7 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs0, lane1: bs1}, } - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) q0 := i.lanes[lane0].blocks @@ -249,7 +249,7 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Create 3 sequential CommitQCs. qcs := make([]*types.CommitQC, 3) @@ -268,7 +268,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // Without anchor, commitQCs.first = 0. All 3 should be restored. @@ -286,7 +286,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // AppQC at road index 2. roadIdx := types.RoadIndex(2) @@ -314,7 +314,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // latestAppQC should be set by prune. @@ -338,7 +338,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { func TestNewInnerLoadedAllThree(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // AppQC at road index 2. @@ -375,7 +375,7 @@ func TestNewInnerLoadedAllThree(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // AppQC restored. @@ -401,10 +401,10 @@ func TestNewInnerLoadedAllThree(t *testing.T) { func TestPruneAdvancesNextBlockToPersist(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.None[*loadedAvailState]()) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.None[*loadedAvailState]()) require.NoError(t, err) // Push blocks 0-4 on one lane. @@ -460,7 +460,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Build 6 CommitQCs (indices 0-5). Anchor at index 5. // All stale commitQCs (0-4) were already filtered by loadPersistedState, @@ -479,7 +479,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[5]}), } - inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // tipcut insert after prune places the anchor's CommitQC. @@ -490,7 +490,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Simulate crash between anchor write and CommitQC file write: // anchor has AppQC@3 + CommitQC@3, but no CommitQC files on disk. @@ -508,7 +508,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), } - inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // tipcut insert after prune places the anchor's CommitQC. @@ -530,7 +530,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() @@ -551,20 +551,20 @@ func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { rng := utils.TestRng() - registry, _, _ := epoch.GenRegistry(rng, 4) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) loaded := &loadedAvailState{ commitQCs: nil, } - inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) @@ -575,7 +575,7 @@ func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Simulate crash scenario: disk had stale QCs [0,1,2] and a new QC at // index 10. loadPersistedState pre-filters stale entries, so newInner @@ -599,7 +599,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // Only QC@10 loaded. @@ -619,7 +619,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Build 6 CommitQCs (0-5). Anchor at index 3. // Loaded list includes stale entries [1, 2] below the anchor plus [3, 4, 5]. @@ -648,7 +648,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // tipcut insert places QC@3 (next=4). Indices 1,2,3 are skipped. 4,5 pushed. @@ -661,7 +661,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Anchor at index 2. Loaded commitQCs are [2, 3, 5] — gap at 4. // After prune+tipcut insert at 2, next=3. Index 2 is skipped, 3 pushed (next=4), @@ -687,14 +687,14 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // Blocks 3, 4, 6, 7 with no anchor — queue starts at 0, so block 3 @@ -711,14 +711,14 @@ func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // Build blocks 0, 1 with correct chaining, then block 2 with wrong parent. @@ -739,14 +739,14 @@ func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "parent hash mismatch") } func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // Build BlocksPerLane + 5 contiguous blocks — more than the lane capacity. @@ -765,14 +765,14 @@ func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "exceeds capacity") } func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) initialBlock := types.GlobalBlockNumber(0) // Build CommitQCs 0-2. @@ -808,7 +808,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune() should advance block queue first to the prune anchor's lane range. @@ -821,7 +821,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) initialBlock := types.GlobalBlockNumber(0) // Build CommitQCs 0-2. @@ -844,7 +844,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { }, } - i, err := newInner(utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // prune(appQC@1, pruneQC@1) should advance commitQCs.first to 1. @@ -858,7 +858,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { // (queue.prune only advances; a too-high bootstrap would stick). func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) ep0 := utils.OrPanic1(registry.EpochAt(0)) qcs := make([]*types.CommitQC, 3) @@ -887,7 +887,7 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { }, } - inner, err := newInner(tipDuo, utils.Some(loaded)) + inner, err := newInner(registry, tipDuo, utils.Some(loaded)) require.NoError(t, err) require.Equal(t, wantAppFirst, inner.appVotes.first, "appVotes must follow prune-anchor GlobalRange, not tip Current.FirstBlock") @@ -896,10 +896,10 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { rng := utils.TestRng() - registry, _, _ := epoch.GenRegistry(rng, 4) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) duo := utils.OrPanic1(registry.DuoAt(0)) - i, err := newInner(duo, utils.None[*loadedAvailState]()) + i, err := newInner(registry, duo, utils.None[*loadedAvailState]()) require.NoError(t, err) // All current lanes are present after construction. @@ -925,10 +925,10 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { func TestAdvanceEpoch_EmptyQueuesNoop(t *testing.T) { rng := utils.TestRng() - registry, _, _ := epoch.GenRegistry(rng, 4) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) duo := utils.OrPanic1(registry.DuoAt(0)) - i, err := newInner(duo, utils.None[*loadedAvailState]()) + i, err := newInner(registry, duo, utils.None[*loadedAvailState]()) require.NoError(t, err) // No votes in any queue; advancing to the same duo is a safe no-op that @@ -941,11 +941,11 @@ func TestAdvanceEpoch_EmptyQueuesNoop(t *testing.T) { func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { rng := utils.TestRng() - registry, _, _ := epoch.GenRegistry(rng, 4) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) // duo0: Prev=nil, Current=epoch0 duo0 := utils.OrPanic1(registry.DuoAt(0)) - i, err := newInner(duo0, utils.None[*loadedAvailState]()) + i, err := newInner(registry, duo0, utils.None[*loadedAvailState]()) require.NoError(t, err) // Collect a lane from epoch0 (will become Prev after advance). diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index d5d22bd8ee..b1b8748e7c 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -160,6 +160,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin // Operating duo is the CommitQC tipcut (not the prune-anchor road). // Epoch seeding is owned by data.NewState (SetupInitialDuo); this tip // must fall in that window (avail may not lead data across an unseeded epoch). + // newInner re-verifies loaded CommitQCs so consensus can trust latestCommitQC. commitTip := types.RoadIndex(0) if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() @@ -168,7 +169,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin if err != nil { return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) } - inner, err := newInner(startDuo, loaded) + inner, err := newInner(data.Registry(), startDuo, loaded) if err != nil { return nil, err } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 3b9fa5592d..a1309c5ce9 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -476,7 +476,8 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { func TestNewStateWithPersistence(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 4) + // Road-0 CommitQC chains: pin genesis epoch so LatestEpoch matches roads. + registry, keys := epoch.GenRegistryAt(rng, 4, 0) initialBlock := types.GlobalBlockNumber(0) t.Run("empty dir loads fresh state", func(t *testing.T) { @@ -1107,7 +1108,7 @@ func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { require.NoError(t, err) require.Equal(t, types.EpochIndex(2), tipDuo2.Current.EpochIndex()) - _, err = newInner(tipDuo2, utils.None[*loadedAvailState]()) + _, err = newInner(registry, tipDuo2, utils.None[*loadedAvailState]()) require.Error(t, err, "epoch > 0 requires a prune anchor") } diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 552aed8de1..900af4c1cf 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -152,8 +152,18 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( } func (s *State) pushCommitQC(qc *types.CommitQC) error { - // Trust avail's tip: PushCommitQC already verified at admit. Consensus only - // adopts View and keeps epochs aligned with the tipcut (Index+1). + if qc.Proposal().Index() < s.innerRecv.Load().View().Index { + return nil + } + // Re-verify like main (defense in depth on avail's tip). Epoch must already + // be seeded; hard-error if missing — do not WaitForDuo. + ep, err := s.registry.EpochAt(qc.Proposal().Index()) + if err != nil { + return fmt.Errorf("EpochAt(%d): %w", qc.Proposal().Index(), err) + } + if err := qc.Verify(ep); err != nil { + return fmt.Errorf("qc.Verify(): %w", err) + } for iSend := range s.inner.Lock() { i := iSend.Load() if qc.Proposal().Index() < i.View().Index { @@ -164,7 +174,6 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { if !i.epochs.Current.RoadRange().Has(nextRoad) { // Boundary or coalesced tip past Current: open duo at tipcut. // Invariant: seeded before this tip (SetupInitialDuo / AdvanceIfNeeded). - // Hard-error if missing — do not WaitForDuo like avail/data. duo, err := s.registry.DuoAt(nextRoad) if err != nil { logger.Error("tipcut duo not in registry after avail CommitQC tip", From 8215ee98e602ba5840d4f0d6f1985d727ed7aebb Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 08:36:11 -0700 Subject: [PATCH 77/98] fix(autobahn): wait for registry epoch N+1 before admitting CommitQC in N MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate PushCommitQC on epoch existence (WaitForDuo) alongside AppQC, and document the coarse commit↔execution tip interlock on Registry. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 16 ++++-- .../internal/autobahn/avail/state_test.go | 57 +++++++++++++++++++ .../internal/autobahn/data/state.go | 7 +-- .../internal/autobahn/epoch/registry.go | 29 +++++++++- 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index b1b8748e7c..dd068f04bc 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -10,6 +10,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail/metrics" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/protoutils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -358,8 +359,8 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi // PushCommitQC pushes a CommitQC to the state. // Waits for prior CommitQCs, for Current to cover the road (too early blocks; -// too late / stale is dropped), and for AppQC of epoch N before accepting a -// CommitQC from N+1 (avail tip at most one epoch ahead of the AppQC anchor). +// too late / stale is dropped), for AppQC of epoch N-1 before accepting a +// CommitQC from N, and for registry epoch N+1 (see tip-interlock docs). func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() if idx > 0 { @@ -378,12 +379,16 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return fmt.Errorf("qc.Verify(): %w", err) } // Gate on the verified epoch; don't blindly trust qc.Proposal().EpochIndex(). - // Epochs are numbered from 0 (unlike global block numbers): epoch 0 has no - // prior AppQC; N+1 requires AppQC of N. + // Epoch 0 has no prior leash; N requires AppQC of N-1 and registry epoch N+1 + // (see Registry tip-interlock). Wait — do not drop (caller may not retry). if epochIdx := ep.EpochIndex(); epochIdx > 0 { if err := s.waitForAppQCEpoch(ctx, epochIdx-1); err != nil { return err } + // N+1 existing is the unlock signal (usually from AdvanceIfNeeded). + if _, err := s.data.Registry().WaitForDuo(ctx, epoch.FirstRoad(epochIdx+1)); err != nil { + return err + } } // Boundary: switch to the next epoch on Current's last CommitQC. @@ -691,9 +696,10 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful } // Collect the headers from the votes. var commitHeaders []*types.BlockHeader + // Duo only — see epoch.Registry tip-interlock. Miss ≠ prune; must not skip. ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { - // Window raced past this road ⇒ treat as pruned for the PushQC loop. + // TODO(autobahn): hard-fail under interlock; ErrPruned+skip is wrong. return nil, nil, fmt.Errorf("epoch window advanced past road %d: %w", qc.Proposal().Index(), data.ErrPruned) } for lane := range ep.Committee().Lanes().All() { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index a1309c5ce9..7f893d6ccf 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1022,6 +1022,63 @@ func TestPushCommitQCStaleDrops(t *testing.T) { require.False(t, state.LastCommitQC().Load().IsPresent(), "stale CommitQC must not be admitted") } +// TestPushCommitQCWaitsForEpochUnlock: AppQC of N-1 is present but registry +// lacks epoch N+1 — PushCommitQC waits for N+1, does not drop. +func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; epoch 2 absent + ep0 := utils.OrPanic1(registry.EpochAt(0)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + _, err := registry.EpochAt(epoch.FirstRoad(2)) + require.Error(t, err, "test setup: epoch 2 must be absent") + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + + lane := keys[0].Public() + block0 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block0.Header()))}, + utils.None[*types.AppQC]()) + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0), + }))), + }) + block1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc1 := makeCommitQC(ep1, keys, utils.Some(prevTip), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block1.Header()))}, + utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(1), qc1.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC0) + inner.commitQCs.first = epoch.FirstRoad(1) + inner.commitQCs.next = epoch.FirstRoad(1) + } + state.markCommitQCsPersisted(prevTip) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushCommitQC(ctx, qc1), context.Canceled) + + // Unlock epoch 2 (as AdvanceIfNeeded after last road of epoch 0), then admit. + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + require.NoError(t, state.PushCommitQC(t.Context(), qc1)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.FirstRoad(1)+1, inner.commitQCs.next) + require.NotNil(t, inner.commitQCs.q[epoch.FirstRoad(1)]) + } +} + func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { registry, keys := epoch.GenRegistryAt(utils.TestRng(), 4, 0) // {0,1}; Current starts at epoch 0 ep0 := utils.OrPanic1(registry.EpochAt(0)) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index d5e6cefeb7..8d92397010 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -308,13 +308,12 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if dataFirst > cfg.Registry.FirstBlock() { inner.skipTo(dataFirst) } - // Seed epochs once here (avail/consensus do not call SetupInitialDuo). - // Seed {firstLoaded..tip} so every retained CommitQC has an epoch — stand-in - // for reading committee/epoch info from blocks once that is on-chain. + // Seed epochs once here (see epoch.Registry tip-interlock: data is restart + // anchor). Seed {firstLoaded..tip} so every retained CommitQC has an epoch — + // stand-in for reading committee info from blocks once that is on-chain. // Execution tip only adds AdvanceIfNeeded lookahead via EnsureAfterExecuted; // if LastExecutedBlock's QC was pruned from the WAL, roadForGlobal is None // and that lookahead is skipped — live AdvanceIfNeeded re-seeds forward. - // Consensus/avail tips must not lead this window across an unseeded epoch. // // TODO(autobahn): LastExecutedBlock comes from app.LastBlockHeight() (app // state DB). Move execution tip into Giga storage; stop reading the app DB. diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index f5632b8291..1f0a3c71e9 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -38,6 +38,32 @@ type registryState struct { // Registry is the authoritative source of epoch and committee information. // All layers (consensus, data, avail) read from it. +// +// # Tip interlocking (commit ↔ execution) +// +// Commit and execution are independent pipelines but must stay interlocked: +// +// - Execution cannot pass commit. +// - Epoch unlock is coarse: last road of N-1 registers epoch N+1 +// (AdvanceIfNeeded). Avail does not track per-road exec progress — it only +// gates when admitting CommitQCs whose epoch is N (>0): N+1 must already +// exist, so commit cannot enter N until N-1 has finished. +// - Do not soft-prune, skip roads, or invent epochs to "fix" tip skew. +// +// Two leashes on avail PushCommitQC (for CommitQC epoch N > 0): +// +// - AppQC of N-1 — network/prune (CommitQC WAL ~≤1 epoch); wait for AppQC. +// - Registry has epoch N+1 — wait via WaitForDuo (same catch-up idea as AppQC). +// Gate on epoch existence, not PushAppHash / local-exec cursors. +// +// Restart: +// +// - data/ is the anchor (SetupInitialDuo only from data.NewState). +// - Avail/consensus must not lead data across an unseeded epoch. +// - Holes or a full-epoch lead → hard-fail (data-sync / wipe avail+consensus), +// not soft-heal. +// - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for +// load/verify and WaitForDuo, not a substitute for the interlock. type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. @@ -74,8 +100,7 @@ type CommitQCSpan struct { } // SetupInitialDuo seeds placeholder epochs on restart. Called only from -// data.NewState (avail/consensus rely on that window; their tips must not lead -// data across an unseeded epoch). +// data.NewState (see Registry tip-interlock docs). Avail/consensus do not seed. // // 1. Seed every epoch from commitQCs.First through commitQCs.Last (the loaded // WAL span). Stand-in for reading committee/epoch info from retained blocks; From cbdeb361e6ce287cb2aa0c25afca6b0cc5a70618 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 08:36:55 -0700 Subject: [PATCH 78/98] fix(autobahn): hard-fail fullCommitQC on duo miss instead of ErrPruned skip Export must stay contiguous for data.PushQC; only skip when the CommitQC itself was pruned behind FirstCommitQC. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 14 ++++++---- .../internal/autobahn/avail/state_test.go | 28 +++++++++++++++++++ .../internal/autobahn/epoch/registry.go | 2 ++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index dd068f04bc..dcaea56734 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -694,14 +694,13 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful if err != nil { return nil, nil, err } - // Collect the headers from the votes. - var commitHeaders []*types.BlockHeader - // Duo only — see epoch.Registry tip-interlock. Miss ≠ prune; must not skip. + // Lane set from the live duo (see Registry tip-interlock). A miss is a bug + // under the interlock — hard-fail, not ErrPruned (export must not skip). ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { - // TODO(autobahn): hard-fail under interlock; ErrPruned+skip is wrong. - return nil, nil, fmt.Errorf("epoch window advanced past road %d: %w", qc.Proposal().Index(), data.ErrPruned) + return nil, nil, fmt.Errorf("road %d not in epoch duo %v: %w", qc.Proposal().Index(), s.epochDuo.Load(), err) } + var commitHeaders []*types.BlockHeader for lane := range ep.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) if err != nil { @@ -806,7 +805,10 @@ func (s *State) Run(ctx context.Context) error { for n := types.RoadIndex(0); ; n = max(n+1, s.FirstCommitQC()) { qc, _, err := s.fullCommitQC(ctx, n) if err != nil { - if errors.Is(err, data.ErrPruned) { + // Only skip when this road's CommitQC was pruned (export + // cursor behind FirstCommitQC). Duo miss / header prune + // must not skip — data rejects QC gaps. + if errors.Is(err, data.ErrPruned) && n < s.FirstCommitQC() { continue } return err diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 7f893d6ccf..c246af4c28 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1022,6 +1022,34 @@ func TestPushCommitQCStaleDrops(t *testing.T) { require.False(t, state.LastCommitQC().Load().IsPresent(), "stale CommitQC must not be admitted") } +// TestFullCommitQCDuoMissIsNotPruned: CommitQC still held but duo has moved past +// its road — hard-fail (not ErrPruned) so the PushQC loop cannot skip a gap. +func TestFullCommitQCDuoMissIsNotPruned(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + lane := keys[0].Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block.Header()))}, + utils.None[*types.AppQC]()) + require.NoError(t, state.PushCommitQC(t.Context(), qc0)) + state.markCommitQCsPersisted(qc0) + + // Slide duo so road 0 is no longer in Prev|Current. + registerDuoAtEpoch(state, 2) + + _, _, err = state.fullCommitQC(t.Context(), 0) + require.Error(t, err) + require.False(t, errors.Is(err, data.ErrPruned), "duo miss must not look like prune") +} + // TestPushCommitQCWaitsForEpochUnlock: AppQC of N-1 is present but registry // lacks epoch N+1 — PushCommitQC waits for N+1, does not drop. func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 1f0a3c71e9..41f2843e8c 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -64,6 +64,8 @@ type registryState struct { // not soft-heal. // - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for // load/verify and WaitForDuo, not a substitute for the interlock. +// - FullCommitQC export must not treat a duo miss as pruned / skip a road +// (data rejects QC gaps); hard-fail instead. type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. From 61cc882613834f4154dc465929c28dd97e480c10 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 08:38:39 -0700 Subject: [PATCH 79/98] fix(autobahn): fullCommitQC never returns ErrPruned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export loop starts at FirstCommitQC and treats any fullCommitQC error as fatal — no caller-side prune guessing. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 21 +++++++++++-------- .../internal/autobahn/avail/state_test.go | 4 ++-- .../internal/autobahn/epoch/registry.go | 4 ++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index dcaea56734..d1ab5879d5 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -688,14 +688,18 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc } // fullCommitQC returns the FullCommitQC for road n and the signing epoch. +// Never returns data.ErrPruned — the PushQC export loop advances via +// FirstCommitQC() and treats any error here as fatal (no skip/guess). func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { - // Collect the CommitQC. qc, err := s.CommitQC(ctx, n) if err != nil { + if errors.Is(err, data.ErrPruned) { + return nil, nil, fmt.Errorf("CommitQC %d pruned during export", n) + } return nil, nil, err } // Lane set from the live duo (see Registry tip-interlock). A miss is a bug - // under the interlock — hard-fail, not ErrPruned (export must not skip). + // under the interlock — hard-fail (export must not skip). ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { return nil, nil, fmt.Errorf("road %d not in epoch duo %v: %w", qc.Proposal().Index(), s.epochDuo.Load(), err) @@ -704,6 +708,9 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful for lane := range ep.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) if err != nil { + if errors.Is(err, data.ErrPruned) { + return nil, nil, fmt.Errorf("headers for road %d pruned before export", n) + } return nil, nil, err } commitHeaders = append(commitHeaders, headers...) @@ -802,15 +809,11 @@ func (s *State) Run(ctx context.Context) error { }) // Task inserting FullCommitQCs and local blocks to data state. scope.SpawnNamed("s.data.PushQC", func() error { - for n := types.RoadIndex(0); ; n = max(n+1, s.FirstCommitQC()) { + // Start at FirstCommitQC so pruned roads are never requested; + // fullCommitQC never returns ErrPruned for the caller to interpret. + for n := s.FirstCommitQC(); ; n = max(n+1, s.FirstCommitQC()) { qc, _, err := s.fullCommitQC(ctx, n) if err != nil { - // Only skip when this road's CommitQC was pruned (export - // cursor behind FirstCommitQC). Duo miss / header prune - // must not skip — data rejects QC gaps. - if errors.Is(err, data.ErrPruned) && n < s.FirstCommitQC() { - continue - } return err } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index c246af4c28..4ce7289453 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1023,7 +1023,7 @@ func TestPushCommitQCStaleDrops(t *testing.T) { } // TestFullCommitQCDuoMissIsNotPruned: CommitQC still held but duo has moved past -// its road — hard-fail (not ErrPruned) so the PushQC loop cannot skip a gap. +// its road — error that is not ErrPruned (export must not skip via prune). func TestFullCommitQCDuoMissIsNotPruned(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) @@ -1047,7 +1047,7 @@ func TestFullCommitQCDuoMissIsNotPruned(t *testing.T) { _, _, err = state.fullCommitQC(t.Context(), 0) require.Error(t, err) - require.False(t, errors.Is(err, data.ErrPruned), "duo miss must not look like prune") + require.NotErrorIs(t, err, data.ErrPruned) } // TestPushCommitQCWaitsForEpochUnlock: AppQC of N-1 is present but registry diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 41f2843e8c..3f4360b93c 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -64,8 +64,8 @@ type registryState struct { // not soft-heal. // - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for // load/verify and WaitForDuo, not a substitute for the interlock. -// - FullCommitQC export must not treat a duo miss as pruned / skip a road -// (data rejects QC gaps); hard-fail instead. +// - FullCommitQC export advances from FirstCommitQC(); fullCommitQC never +// returns ErrPruned — duo miss / unexpected prune hard-fail (no skip). type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. From dd6d4a80d6864409f8c6029567fe55424aadd2df Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 08:39:48 -0700 Subject: [PATCH 80/98] fix(autobahn): only return ErrPruned from fullCommitQC on real prune Duo miss hard-fails; the export loop can keep skipping on ErrPruned without guessing whether the road was pruned or merely out of the duo window. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 21 +++++++------------ .../internal/autobahn/epoch/registry.go | 4 ++-- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index d1ab5879d5..8eb4d10237 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -688,18 +688,15 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc } // fullCommitQC returns the FullCommitQC for road n and the signing epoch. -// Never returns data.ErrPruned — the PushQC export loop advances via -// FirstCommitQC() and treats any error here as fatal (no skip/guess). +// Returns data.ErrPruned only when the CommitQC or its vote headers were +// actually pruned. A duo miss is not prune — that hard-fails so the export +// loop does not skip a still-retained road. func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { qc, err := s.CommitQC(ctx, n) if err != nil { - if errors.Is(err, data.ErrPruned) { - return nil, nil, fmt.Errorf("CommitQC %d pruned during export", n) - } return nil, nil, err } - // Lane set from the live duo (see Registry tip-interlock). A miss is a bug - // under the interlock — hard-fail (export must not skip). + // Lane set from the live duo (see Registry tip-interlock). ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { return nil, nil, fmt.Errorf("road %d not in epoch duo %v: %w", qc.Proposal().Index(), s.epochDuo.Load(), err) @@ -708,9 +705,6 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful for lane := range ep.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) if err != nil { - if errors.Is(err, data.ErrPruned) { - return nil, nil, fmt.Errorf("headers for road %d pruned before export", n) - } return nil, nil, err } commitHeaders = append(commitHeaders, headers...) @@ -809,11 +803,12 @@ func (s *State) Run(ctx context.Context) error { }) // Task inserting FullCommitQCs and local blocks to data state. scope.SpawnNamed("s.data.PushQC", func() error { - // Start at FirstCommitQC so pruned roads are never requested; - // fullCommitQC never returns ErrPruned for the caller to interpret. - for n := s.FirstCommitQC(); ; n = max(n+1, s.FirstCommitQC()) { + for n := types.RoadIndex(0); ; n = max(n+1, s.FirstCommitQC()) { qc, _, err := s.fullCommitQC(ctx, n) if err != nil { + if errors.Is(err, data.ErrPruned) { + continue + } return err } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 3f4360b93c..4876e070ab 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -64,8 +64,8 @@ type registryState struct { // not soft-heal. // - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for // load/verify and WaitForDuo, not a substitute for the interlock. -// - FullCommitQC export advances from FirstCommitQC(); fullCommitQC never -// returns ErrPruned — duo miss / unexpected prune hard-fail (no skip). +// - FullCommitQC export: real prune → ErrPruned (caller skips). Duo miss is +// not prune — hard-fail so a retained road is never skipped. type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. From c986e789bc0c3656105ca4381376f62b16487a98 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 08:43:11 -0700 Subject: [PATCH 81/98] fix(autobahn): classify duo roads as before/after/in window for export EpochDuo.Relation distinguishes older (ErrPruned) from newer (wait for advanceEpoch) so fullCommitQC no longer conflates them. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 23 +++++++++++++ .../autobahn/types/epoch_duo_test.go | 24 +++++++++++++ .../internal/autobahn/avail/state.go | 34 +++++++++++++++---- .../internal/autobahn/avail/state_test.go | 29 ++++++++++++---- .../internal/autobahn/epoch/registry.go | 4 +-- 5 files changed, 100 insertions(+), 14 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 60a6e9743f..5b8dd72c02 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -16,10 +16,33 @@ type EpochDuo struct { Current *Epoch } +// RoadRelation is where a road sits relative to Prev|Current. +type RoadRelation int + +const ( + // RoadInWindow: road is in Prev or Current. + RoadInWindow RoadRelation = iota + // RoadBeforeWindow: road is older than WindowFirst (behind the duo). + RoadBeforeWindow + // RoadAfterWindow: road is newer than Current (at or past Current.Next). + RoadAfterWindow +) + func (w EpochDuo) all() [2]utils.Option[*Epoch] { return [2]utils.Option[*Epoch]{utils.Some(w.Current), w.Prev} } +// Relation returns whether roadIdx is in, before, or after the duo window. +func (w EpochDuo) Relation(roadIdx RoadIndex) RoadRelation { + if _, err := w.EpochForRoad(roadIdx); err == nil { + return RoadInWindow + } + if roadIdx < w.WindowFirst() { + return RoadBeforeWindow + } + return RoadAfterWindow +} + // EpochForRoad returns the epoch whose road range contains roadIdx. // Prefers Current so an open-range Prev cannot shadow later epochs. func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 2c121164ea..9d433808ff 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -77,6 +77,30 @@ func TestEpochForRoad_AbsentPrevSkipped(t *testing.T) { } } +func TestRelation(t *testing.T) { + prev, current := testDuoEpochs(t) + w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + if got := w.Relation(50); got != types.RoadInWindow { + t.Fatalf("Relation(50) = %v, want InWindow", got) + } + if got := w.Relation(150); got != types.RoadInWindow { + t.Fatalf("Relation(150) = %v, want InWindow", got) + } + if got := w.Relation(0); got != types.RoadInWindow { // prev starts at 0 + t.Fatalf("Relation(0) = %v, want InWindow", got) + } + wCurrentOnly := types.EpochDuo{Current: current} + if got := wCurrentOnly.Relation(50); got != types.RoadBeforeWindow { + t.Fatalf("Relation(50) current-only = %v, want BeforeWindow", got) + } + if got := w.Relation(200); got != types.RoadAfterWindow { + t.Fatalf("Relation(200) = %v, want AfterWindow", got) + } + if got := w.Relation(999); got != types.RoadAfterWindow { + t.Fatalf("Relation(999) = %v, want AfterWindow", got) + } +} + func TestWindowFirst_WithPrev(t *testing.T) { prev, current := testDuoEpochs(t) w := types.EpochDuo{Prev: utils.Some(prev), Current: current} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 8eb4d10237..bd6d829093 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -688,18 +688,18 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc } // fullCommitQC returns the FullCommitQC for road n and the signing epoch. -// Returns data.ErrPruned only when the CommitQC or its vote headers were -// actually pruned. A duo miss is not prune — that hard-fails so the export -// loop does not skip a still-retained road. +// Returns data.ErrPruned when the CommitQC/headers were pruned, or when the +// road is behind the epoch duo (RoadBeforeWindow). If the duo has not yet +// advanced to cover the road (RoadAfterWindow), waits for advanceEpoch. func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { qc, err := s.CommitQC(ctx, n) if err != nil { return nil, nil, err } - // Lane set from the live duo (see Registry tip-interlock). - ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) + road := qc.Proposal().Index() + ep, err := s.waitEpochForExport(ctx, road) if err != nil { - return nil, nil, fmt.Errorf("road %d not in epoch duo %v: %w", qc.Proposal().Index(), s.epochDuo.Load(), err) + return nil, nil, err } var commitHeaders []*types.BlockHeader for lane := range ep.Committee().Lanes().All() { @@ -712,6 +712,28 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful return types.NewFullCommitQC(qc, commitHeaders), ep, nil } +// waitEpochForExport resolves the signing epoch for an already-admitted CommitQC +// road (see Registry tip-interlock / EpochDuo.Relation). +func (s *State) waitEpochForExport(ctx context.Context, road types.RoadIndex) (*types.Epoch, error) { + for inner, ctrl := range s.inner.Lock() { + for { + duo := inner.epochDuo.Load() + switch duo.Relation(road) { + case types.RoadInWindow: + return duo.EpochForRoad(road) + case types.RoadBeforeWindow: + return nil, data.ErrPruned + case types.RoadAfterWindow: + // Duo has not advanced to this road yet; wait for advanceEpoch. + if err := ctrl.Wait(ctx); err != nil { + return nil, err + } + } + } + } + panic("unreachable") +} + // WaitForLocalCapacity waits until the lane owned by this node has capacity for toProduce block. func (s *State) WaitForLocalCapacity(ctx context.Context, toProduce types.BlockNumber) error { lane := s.key.Public() diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 4ce7289453..f27d8c4cf1 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1022,9 +1022,9 @@ func TestPushCommitQCStaleDrops(t *testing.T) { require.False(t, state.LastCommitQC().Load().IsPresent(), "stale CommitQC must not be admitted") } -// TestFullCommitQCDuoMissIsNotPruned: CommitQC still held but duo has moved past -// its road — error that is not ErrPruned (export must not skip via prune). -func TestFullCommitQCDuoMissIsNotPruned(t *testing.T) { +// TestFullCommitQCBeforeWindowIsPruned: CommitQC still held but duo has moved +// past its road (RoadBeforeWindow) — ErrPruned so the export loop can skip. +func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) ep0 := utils.OrPanic1(registry.EpochAt(0)) @@ -1042,12 +1042,29 @@ func TestFullCommitQCDuoMissIsNotPruned(t *testing.T) { require.NoError(t, state.PushCommitQC(t.Context(), qc0)) state.markCommitQCsPersisted(qc0) - // Slide duo so road 0 is no longer in Prev|Current. + // Slide duo so road 0 is behind WindowFirst. registerDuoAtEpoch(state, 2) _, _, err = state.fullCommitQC(t.Context(), 0) - require.Error(t, err) - require.NotErrorIs(t, err, data.ErrPruned) + require.ErrorIs(t, err, data.ErrPruned) +} + +// TestWaitEpochForExportAfterWindowWaits: road ahead of Current waits until the +// duo advances (or ctx cancels). +func TestWaitEpochForExportAfterWindowWaits(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // Current is epoch 0; FirstRoad(1) is AfterWindow. + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = state.waitEpochForExport(ctx, epoch.FirstRoad(1)) + require.ErrorIs(t, err, context.Canceled) } // TestPushCommitQCWaitsForEpochUnlock: AppQC of N-1 is present but registry diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 4876e070ab..68018e56db 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -64,8 +64,8 @@ type registryState struct { // not soft-heal. // - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for // load/verify and WaitForDuo, not a substitute for the interlock. -// - FullCommitQC export: real prune → ErrPruned (caller skips). Duo miss is -// not prune — hard-fail so a retained road is never skipped. +// - FullCommitQC export uses EpochDuo.Relation: before window → ErrPruned +// (caller skips); after window → wait for advanceEpoch; in window → proceed. type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. From ab8d98e1a073be65abf0bd0a39c3b49db3eb20ad Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 08:45:40 -0700 Subject: [PATCH 82/98] refactor(autobahn): EpochForRoad returns before/after window errors Replace Relation with ErrRoadBeforeWindow / ErrRoadAfterWindow; export maps before-window to data.ErrPruned and waits on after-window. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 34 ++++++---------- .../autobahn/types/epoch_duo_test.go | 40 +++++++++---------- .../internal/autobahn/avail/state.go | 31 +++++++------- .../internal/autobahn/avail/state_test.go | 2 +- .../internal/autobahn/epoch/registry.go | 4 +- 5 files changed, 50 insertions(+), 61 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 5b8dd72c02..54ea9badbf 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -1,6 +1,7 @@ package types import ( + "errors" "fmt" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" @@ -16,42 +17,31 @@ type EpochDuo struct { Current *Epoch } -// RoadRelation is where a road sits relative to Prev|Current. -type RoadRelation int +// ErrRoadBeforeWindow is returned by EpochForRoad when the road is older than +// WindowFirst (behind the duo). +var ErrRoadBeforeWindow = errors.New("road before epoch duo window") -const ( - // RoadInWindow: road is in Prev or Current. - RoadInWindow RoadRelation = iota - // RoadBeforeWindow: road is older than WindowFirst (behind the duo). - RoadBeforeWindow - // RoadAfterWindow: road is newer than Current (at or past Current.Next). - RoadAfterWindow -) +// ErrRoadAfterWindow is returned by EpochForRoad when the road is newer than +// Current (at or past Current.Next). +var ErrRoadAfterWindow = errors.New("road after epoch duo window") func (w EpochDuo) all() [2]utils.Option[*Epoch] { return [2]utils.Option[*Epoch]{utils.Some(w.Current), w.Prev} } -// Relation returns whether roadIdx is in, before, or after the duo window. -func (w EpochDuo) Relation(roadIdx RoadIndex) RoadRelation { - if _, err := w.EpochForRoad(roadIdx); err == nil { - return RoadInWindow - } - if roadIdx < w.WindowFirst() { - return RoadBeforeWindow - } - return RoadAfterWindow -} - // EpochForRoad returns the epoch whose road range contains roadIdx. // Prefers Current so an open-range Prev cannot shadow later epochs. +// Returns ErrRoadBeforeWindow or ErrRoadAfterWindow when out of window. func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { for _, oep := range w.all() { if ep, ok := oep.Get(); ok && ep.RoadRange().Has(roadIdx) { return ep, nil } } - return nil, fmt.Errorf("road %d not in window %v", roadIdx, w) + if roadIdx < w.WindowFirst() { + return nil, fmt.Errorf("road %d before window %v: %w", roadIdx, w, ErrRoadBeforeWindow) + } + return nil, fmt.Errorf("road %d after window %v: %w", roadIdx, w, ErrRoadAfterWindow) } // EpochOptForRoad is EpochForRoad as an Option (None when out of window). diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 9d433808ff..2b91986489 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -1,6 +1,7 @@ package types_test import ( + "errors" "testing" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" @@ -47,8 +48,13 @@ func TestEpochForRoad_HitsPrevEpoch(t *testing.T) { func TestEpochForRoad_OutsideWindowReturnsError(t *testing.T) { _, current := testDuoEpochs(t) w := types.EpochDuo{Current: current} - if _, err := w.EpochForRoad(999); err == nil { - t.Fatal("EpochForRoad(999) expected error, got nil") + _, err := w.EpochForRoad(999) + if !errors.Is(err, types.ErrRoadAfterWindow) { + t.Fatalf("EpochForRoad(999) = %v, want ErrRoadAfterWindow", err) + } + _, err = w.EpochForRoad(50) + if !errors.Is(err, types.ErrRoadBeforeWindow) { + t.Fatalf("EpochForRoad(50) current-only = %v, want ErrRoadBeforeWindow", err) } } @@ -72,32 +78,24 @@ func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { func TestEpochForRoad_AbsentPrevSkipped(t *testing.T) { _, current := testDuoEpochs(t) w := types.EpochDuo{Current: current} - if _, err := w.EpochForRoad(50); err == nil { - t.Fatal("EpochForRoad(50) with absent Prev expected error, got nil") + _, err := w.EpochForRoad(50) + if !errors.Is(err, types.ErrRoadBeforeWindow) { + t.Fatalf("EpochForRoad(50) with absent Prev = %v, want ErrRoadBeforeWindow", err) } } -func TestRelation(t *testing.T) { +func TestEpochForRoad_BeforeAndAfterWithPrev(t *testing.T) { prev, current := testDuoEpochs(t) w := types.EpochDuo{Prev: utils.Some(prev), Current: current} - if got := w.Relation(50); got != types.RoadInWindow { - t.Fatalf("Relation(50) = %v, want InWindow", got) - } - if got := w.Relation(150); got != types.RoadInWindow { - t.Fatalf("Relation(150) = %v, want InWindow", got) - } - if got := w.Relation(0); got != types.RoadInWindow { // prev starts at 0 - t.Fatalf("Relation(0) = %v, want InWindow", got) - } - wCurrentOnly := types.EpochDuo{Current: current} - if got := wCurrentOnly.Relation(50); got != types.RoadBeforeWindow { - t.Fatalf("Relation(50) current-only = %v, want BeforeWindow", got) + if _, err := w.EpochForRoad(50); err != nil { + t.Fatalf("EpochForRoad(50) in prev: %v", err) } - if got := w.Relation(200); got != types.RoadAfterWindow { - t.Fatalf("Relation(200) = %v, want AfterWindow", got) + if _, err := w.EpochForRoad(150); err != nil { + t.Fatalf("EpochForRoad(150) in current: %v", err) } - if got := w.Relation(999); got != types.RoadAfterWindow { - t.Fatalf("Relation(999) = %v, want AfterWindow", got) + _, err := w.EpochForRoad(200) + if !errors.Is(err, types.ErrRoadAfterWindow) { + t.Fatalf("EpochForRoad(200) = %v, want ErrRoadAfterWindow", err) } } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index bd6d829093..c45a54a658 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -689,15 +689,14 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc // fullCommitQC returns the FullCommitQC for road n and the signing epoch. // Returns data.ErrPruned when the CommitQC/headers were pruned, or when the -// road is behind the epoch duo (RoadBeforeWindow). If the duo has not yet -// advanced to cover the road (RoadAfterWindow), waits for advanceEpoch. +// road is behind the epoch duo (ErrRoadBeforeWindow). If the duo has not yet +// advanced to cover the road (ErrRoadAfterWindow), waits for advanceEpoch. func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { qc, err := s.CommitQC(ctx, n) if err != nil { return nil, nil, err } - road := qc.Proposal().Index() - ep, err := s.waitEpochForExport(ctx, road) + ep, err := s.waitEpochForExport(ctx, qc.Proposal().Index()) if err != nil { return nil, nil, err } @@ -713,21 +712,23 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful } // waitEpochForExport resolves the signing epoch for an already-admitted CommitQC -// road (see Registry tip-interlock / EpochDuo.Relation). +// road (see Registry tip-interlock). Before-window maps to data.ErrPruned so the +// PushQC loop can skip; after-window waits for advanceEpoch. func (s *State) waitEpochForExport(ctx context.Context, road types.RoadIndex) (*types.Epoch, error) { for inner, ctrl := range s.inner.Lock() { for { - duo := inner.epochDuo.Load() - switch duo.Relation(road) { - case types.RoadInWindow: - return duo.EpochForRoad(road) - case types.RoadBeforeWindow: + ep, err := inner.epochDuo.Load().EpochForRoad(road) + if err == nil { + return ep, nil + } + if errors.Is(err, types.ErrRoadBeforeWindow) { return nil, data.ErrPruned - case types.RoadAfterWindow: - // Duo has not advanced to this road yet; wait for advanceEpoch. - if err := ctrl.Wait(ctx); err != nil { - return nil, err - } + } + if !errors.Is(err, types.ErrRoadAfterWindow) { + return nil, err + } + if err := ctrl.Wait(ctx); err != nil { + return nil, err } } } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index f27d8c4cf1..2d47243590 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1023,7 +1023,7 @@ func TestPushCommitQCStaleDrops(t *testing.T) { } // TestFullCommitQCBeforeWindowIsPruned: CommitQC still held but duo has moved -// past its road (RoadBeforeWindow) — ErrPruned so the export loop can skip. +// past its road (ErrRoadBeforeWindow) — ErrPruned so the export loop can skip. func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 68018e56db..c970ab21b9 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -64,8 +64,8 @@ type registryState struct { // not soft-heal. // - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for // load/verify and WaitForDuo, not a substitute for the interlock. -// - FullCommitQC export uses EpochDuo.Relation: before window → ErrPruned -// (caller skips); after window → wait for advanceEpoch; in window → proceed. +// - FullCommitQC export: ErrRoadBeforeWindow → data.ErrPruned (caller skips); +// ErrRoadAfterWindow → wait for advanceEpoch. type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. From d6bfd7357b55411287d275c5d2db0287e59c776e Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 08:52:49 -0700 Subject: [PATCH 83/98] fix(autobahn): map duo-before to ErrPruned; do not wait on duo-after Export jumps ahead on old roads like CommitQC prune; a future-road duo miss hard-fails instead of waiting. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 34 ++++-------------- .../internal/autobahn/avail/state_test.go | 36 ++++++++++++++----- .../internal/autobahn/epoch/registry.go | 4 +-- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index c45a54a658..01e7df872e 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -689,15 +689,19 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc // fullCommitQC returns the FullCommitQC for road n and the signing epoch. // Returns data.ErrPruned when the CommitQC/headers were pruned, or when the -// road is behind the epoch duo (ErrRoadBeforeWindow). If the duo has not yet -// advanced to cover the road (ErrRoadAfterWindow), waits for advanceEpoch. +// road is behind the epoch duo (ErrRoadBeforeWindow) so the export loop can +// jump ahead — same as a pruned CommitQC. A road ahead of the duo is unexpected +// for an already-admitted CommitQC and hard-fails (no wait). func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { qc, err := s.CommitQC(ctx, n) if err != nil { return nil, nil, err } - ep, err := s.waitEpochForExport(ctx, qc.Proposal().Index()) + ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { + if errors.Is(err, types.ErrRoadBeforeWindow) { + return nil, nil, data.ErrPruned + } return nil, nil, err } var commitHeaders []*types.BlockHeader @@ -711,30 +715,6 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful return types.NewFullCommitQC(qc, commitHeaders), ep, nil } -// waitEpochForExport resolves the signing epoch for an already-admitted CommitQC -// road (see Registry tip-interlock). Before-window maps to data.ErrPruned so the -// PushQC loop can skip; after-window waits for advanceEpoch. -func (s *State) waitEpochForExport(ctx context.Context, road types.RoadIndex) (*types.Epoch, error) { - for inner, ctrl := range s.inner.Lock() { - for { - ep, err := inner.epochDuo.Load().EpochForRoad(road) - if err == nil { - return ep, nil - } - if errors.Is(err, types.ErrRoadBeforeWindow) { - return nil, data.ErrPruned - } - if !errors.Is(err, types.ErrRoadAfterWindow) { - return nil, err - } - if err := ctrl.Wait(ctx); err != nil { - return nil, err - } - } - } - panic("unreachable") -} - // WaitForLocalCapacity waits until the lane owned by this node has capacity for toProduce block. func (s *State) WaitForLocalCapacity(ctx context.Context, toProduce types.BlockNumber) error { lane := s.key.Public() diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 2d47243590..f62d5cf6a8 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1023,7 +1023,7 @@ func TestPushCommitQCStaleDrops(t *testing.T) { } // TestFullCommitQCBeforeWindowIsPruned: CommitQC still held but duo has moved -// past its road (ErrRoadBeforeWindow) — ErrPruned so the export loop can skip. +// past its road (ErrRoadBeforeWindow) — ErrPruned so the export loop can jump. func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) @@ -1049,22 +1049,40 @@ func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { require.ErrorIs(t, err, data.ErrPruned) } -// TestWaitEpochForExportAfterWindowWaits: road ahead of Current waits until the -// duo advances (or ctx cancels). -func TestWaitEpochForExportAfterWindowWaits(t *testing.T) { +// TestFullCommitQCAfterWindowHardFails: road ahead of Current is unexpected for +// an admitted CommitQC — ErrRoadAfterWindow, not a wait. +func TestFullCommitQCAfterWindowHardFails(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - // Current is epoch 0; FirstRoad(1) is AfterWindow. - ctx, cancel := context.WithCancel(t.Context()) - cancel() - _, err = state.waitEpochForExport(ctx, epoch.FirstRoad(1)) - require.ErrorIs(t, err, context.Canceled) + // Plant a CommitQC for epoch 1 while duo is still epoch 0. + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt( + utils.OrPanic1(registry.EpochAt(0)), types.View{EpochIndex: 0, Index: epoch.LastRoad(0)}))), + }) + lane := keys[0].Public() + block1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc1 := makeCommitQC(ep1, keys, utils.Some(prevTip), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block1.Header()))}, + utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(1), qc1.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commitQCs.first = epoch.FirstRoad(1) + inner.commitQCs.next = epoch.FirstRoad(1) + 1 + inner.commitQCs.q[epoch.FirstRoad(1)] = qc1 + } + state.markCommitQCsPersisted(qc1) + + _, _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(1)) + require.ErrorIs(t, err, types.ErrRoadAfterWindow) + require.NotErrorIs(t, err, data.ErrPruned) } // TestPushCommitQCWaitsForEpochUnlock: AppQC of N-1 is present but registry diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index c970ab21b9..00bdf39d83 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -64,8 +64,8 @@ type registryState struct { // not soft-heal. // - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for // load/verify and WaitForDuo, not a substitute for the interlock. -// - FullCommitQC export: ErrRoadBeforeWindow → data.ErrPruned (caller skips); -// ErrRoadAfterWindow → wait for advanceEpoch. +// - FullCommitQC export: ErrRoadBeforeWindow → data.ErrPruned (caller jumps +// ahead, same as pruned CommitQC); ErrRoadAfterWindow hard-fails (no wait). type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. From 4ca12c3405047b7d58bab2a38157190bcd4b77e6 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 09:05:11 -0700 Subject: [PATCH 84/98] fix(autobahn): AdvanceIfNeeded on last global; validate AppQC before wait Call AdvanceIfNeeded only when executing the last global of a closing road so multi-block CommitQCs do not unlock N+2 early. Reject mismatched AppQC/CommitQC pairs before admitRoadOrDrop so a far-future Index cannot stall the wait. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 22 +++++++++---------- .../internal/autobahn/epoch/registry.go | 7 +++--- .../internal/p2p/giga_router_common.go | 8 +++++-- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 01e7df872e..5eb5b1c2cb 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -474,6 +474,17 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } } + // Reject mismatched pairs before waiting on the commitQC road — a far-future + // Index() would otherwise stall admitRoadOrDrop indefinitely. + if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { + return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) + } + if got, want := appQC.Proposal().EpochIndex(), commitQC.Proposal().EpochIndex(); got != want { + return fmt.Errorf("appQC epoch_index %d != commitQC epoch_index %d", got, want) + } + if !commitQC.GlobalRange().Has(appQC.Proposal().GlobalNumber()) { + return fmt.Errorf("appQC GlobalNumber not in commitQC range") + } idx := commitQC.Proposal().Index() ep, err := s.admitRoadOrDrop(ctx, idx, "AppQC", s.waitEpochForRoad) if err != nil || ep == nil { @@ -485,17 +496,6 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ if err := commitQC.Verify(ep); err != nil { return fmt.Errorf("commitQC.Verify(): %w", err) } - if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { - return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) - } - if got, want := appQC.Proposal().EpochIndex(), commitQC.Proposal().EpochIndex(); got != want { - return fmt.Errorf("appQC epoch_index %d != commitQC epoch_index %d", got, want) - } - // Defense-in-depth check, it should never happen that >f validators sign - // a proposal which does not match the commitQC's global range. - if !commitQC.GlobalRange().Has(appQC.Proposal().GlobalNumber()) { - return fmt.Errorf("appQC GlobalNumber not in commitQC range") - } // Tipcut insert of a boundary CommitQC must slide Current like PushCommitQC. var nextDuo *types.EpochDuo if idx+1 == ep.RoadRange().Next { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 00bdf39d83..b350eb707b 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -254,9 +254,10 @@ func (r *Registry) EnsureAfterExecuted(road types.RoadIndex) { } } -// AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed. -// Earlier roads in the epoch are a no-op. Restart uses EnsureAfterExecuted / -// EnsureDuoAt instead of replaying this with synthetic LastRoad tips. +// AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed +// (callers should invoke only on that road's final global block). Earlier roads +// in the epoch are a no-op. Restart uses EnsureAfterExecuted / EnsureDuoAt +// instead of replaying this with synthetic LastRoad tips. // Committee for N+2 is currently the genesis committee. // TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 2b18dea5b5..6ea55ad31a 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -299,13 +299,17 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo if err := r.data.PushAppHash(ctx, b.GlobalNumber, resp.AppHash); err != nil { return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } - // Seed N+2 when executing the last road of an epoch (AdvanceIfNeeded no-ops otherwise). + // Seed N+2 only when the last block of an epoch's closing road is executed. + // A CommitQC can span multiple globals; calling AdvanceIfNeeded on the first + // block of LastRoad(N) would unlock N+2 too early. // TODO: real N+2 committee once execution derives it. qc, err := r.data.QC(ctx, b.GlobalNumber) if err != nil { return nil, fmt.Errorf("r.data.QC(%v): %w", b.GlobalNumber, err) } - r.data.Registry().AdvanceIfNeeded(qc.QC().Proposal().Index()) + if b.GlobalNumber+1 == qc.QC().GlobalRange().Next { + r.data.Registry().AdvanceIfNeeded(qc.QC().Proposal().Index()) + } return commitResp, nil } From 6e088746699e1c0b857b225b24230c3c33cc0ef7 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 09:47:17 -0700 Subject: [PATCH 85/98] fix(autobahn): apply CommitQC epoch leashes on PushAppQC tipcut PushAppQC can tipcut-insert a CommitQC on a separate stream; share waitCommitEpochLeashes with PushCommitQC so epoch N cannot unlock before AppQC of N-1 and registry N+1. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 39 ++++++++----- .../internal/autobahn/avail/state_test.go | 58 +++++++++++++++++++ .../internal/autobahn/epoch/registry.go | 3 +- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 5eb5b1c2cb..cc28831028 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -316,6 +316,21 @@ func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex panic("unreachable") } +// waitCommitEpochLeashes enforces tip-interlock gates before inserting a +// CommitQC for epoch N > 0 (PushCommitQC and PushAppQC tipcut). Epoch 0 has +// no prior leash. See epoch.Registry tip-interlock docs. +func (s *State) waitCommitEpochLeashes(ctx context.Context, epochIdx types.EpochIndex) error { + if epochIdx == 0 { + return nil + } + if err := s.waitForAppQCEpoch(ctx, epochIdx-1); err != nil { + return err + } + // N+1 existing is the unlock signal (usually from AdvanceIfNeeded). + _, err := s.data.Registry().WaitForDuo(ctx, epoch.FirstRoad(epochIdx+1)) + return err +} + // LastAppQC returns the latest observed AppQC. func (s *State) LastAppQC() utils.Option[*types.AppQC] { for inner := range s.inner.Lock() { @@ -359,8 +374,8 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi // PushCommitQC pushes a CommitQC to the state. // Waits for prior CommitQCs, for Current to cover the road (too early blocks; -// too late / stale is dropped), for AppQC of epoch N-1 before accepting a -// CommitQC from N, and for registry epoch N+1 (see tip-interlock docs). +// too late / stale is dropped), and for tip-interlock leashes (see +// waitCommitEpochLeashes / Registry tip-interlock docs). func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() if idx > 0 { @@ -379,16 +394,9 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return fmt.Errorf("qc.Verify(): %w", err) } // Gate on the verified epoch; don't blindly trust qc.Proposal().EpochIndex(). - // Epoch 0 has no prior leash; N requires AppQC of N-1 and registry epoch N+1 - // (see Registry tip-interlock). Wait — do not drop (caller may not retry). - if epochIdx := ep.EpochIndex(); epochIdx > 0 { - if err := s.waitForAppQCEpoch(ctx, epochIdx-1); err != nil { - return err - } - // N+1 existing is the unlock signal (usually from AdvanceIfNeeded). - if _, err := s.data.Registry().WaitForDuo(ctx, epoch.FirstRoad(epochIdx+1)); err != nil { - return err - } + // Wait — do not drop (caller may not retry). + if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex()); err != nil { + return err } // Boundary: switch to the next epoch on Current's last CommitQC. @@ -466,7 +474,8 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] } // PushAppQC pushes an AppQC to the state. It requires a corresponding CommitQC -// as a justification. +// as a justification. Tipcut pushBack of that CommitQC uses the same epoch +// leashes as PushCommitQC (waitCommitEpochLeashes). func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *types.CommitQC) error { // Check whether it is needed before verifying. for inner := range s.inner.Lock() { @@ -496,6 +505,10 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ if err := commitQC.Verify(ep); err != nil { return fmt.Errorf("commitQC.Verify(): %w", err) } + // Same leashes as PushCommitQC: tipcut pushBack is a CommitQC insert path. + if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex()); err != nil { + return err + } // Tipcut insert of a boundary CommitQC must slide Current like PushCommitQC. var nextDuo *types.EpochDuo if idx+1 == ep.RoadRange().Next { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index f62d5cf6a8..7868cd0419 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1142,6 +1142,64 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { } } +// TestPushAppQCWaitsForEpochUnlock: tipcut CommitQC insert via PushAppQC must +// honor the same N+1 registry leash as PushCommitQC (separate AppQC stream). +func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; epoch 2 absent + ep0 := utils.OrPanic1(registry.EpochAt(0)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + _, err := registry.EpochAt(epoch.FirstRoad(2)) + require.Error(t, err, "test setup: epoch 2 must be absent") + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + + lane := keys[0].Public() + block0 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block0.Header()))}, + utils.None[*types.AppQC]()) + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0), + }))), + }) + block1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc1 := makeCommitQC(ep1, keys, utils.Some(prevTip), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block1.Header()))}, + utils.None[*types.AppQC]()) + appQC1 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc1.GlobalRange().First, qc1.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + require.Equal(t, epoch.FirstRoad(1), qc1.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC0) + inner.commitQCs.first = epoch.FirstRoad(1) + inner.commitQCs.next = epoch.FirstRoad(1) + } + state.markCommitQCsPersisted(prevTip) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushAppQC(ctx, appQC1, qc1), context.Canceled) + + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + require.NoError(t, state.PushAppQC(t.Context(), appQC1, qc1)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.FirstRoad(1)+1, inner.commitQCs.next) + require.NotNil(t, inner.commitQCs.q[epoch.FirstRoad(1)]) + } +} + func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { registry, keys := epoch.GenRegistryAt(utils.TestRng(), 4, 0) // {0,1}; Current starts at epoch 0 ep0 := utils.OrPanic1(registry.EpochAt(0)) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index b350eb707b..73994e4f32 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -50,7 +50,8 @@ type registryState struct { // exist, so commit cannot enter N until N-1 has finished. // - Do not soft-prune, skip roads, or invent epochs to "fix" tip skew. // -// Two leashes on avail PushCommitQC (for CommitQC epoch N > 0): +// Two leashes on every avail CommitQC insert for epoch N > 0 — PushCommitQC +// and PushAppQC tipcut pushBack (AppQC/CommitQC arrive on separate streams): // // - AppQC of N-1 — network/prune (CommitQC WAL ~≤1 epoch); wait for AppQC. // - Registry has epoch N+1 — wait via WaitForDuo (same catch-up idea as AppQC). From 50c9088b7073ef6c257f3467cfe6f5ee71775033 Mon Sep 17 00:00:00 2001 From: Wen Date: Sat, 18 Jul 2026 10:35:06 -0700 Subject: [PATCH 86/98] fix(autobahn): require AppQC in E before closing epoch E Enforce the two-epoch avail invariant: the last CommitQC of E waits for AppQC in E so Prev is pruned before the duo drops it, making before-window ErrPruned jumps safe. Count PushAppQC's incoming AppQC toward the leash. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 43 ++++-- .../internal/autobahn/avail/state_test.go | 133 +++++++++++++++++- .../internal/autobahn/epoch/registry.go | 18 ++- 3 files changed, 176 insertions(+), 18 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index cc28831028..96c1fd7a27 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -291,9 +291,14 @@ func (s *State) admitRoadOrDrop( } // waitForAppQCEpoch blocks until latest AppQC is from epochIdx or later. -func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex) error { +// incoming, if present, is the AppQC on this PushAppQC call; it counts before +// prune updates latestAppQC. +func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex, incoming utils.Option[*types.AppQC]) error { for inner, ctrl := range s.inner.Lock() { ready := func() bool { + if c, ok := incoming.Get(); ok && c.Proposal().EpochIndex() >= epochIdx { + return true + } appQC, ok := inner.latestAppQC.Get() if !ok { return false @@ -317,13 +322,28 @@ func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex } // waitCommitEpochLeashes enforces tip-interlock gates before inserting a -// CommitQC for epoch N > 0 (PushCommitQC and PushAppQC tipcut). Epoch 0 has -// no prior leash. See epoch.Registry tip-interlock docs. -func (s *State) waitCommitEpochLeashes(ctx context.Context, epochIdx types.EpochIndex) error { +// CommitQC for epoch N > 0 (PushCommitQC and PushAppQC tipcut). closingEpoch +// is true when this QC is the last road of its epoch (duo will drop Prev). +// incoming is the AppQC on a PushAppQC call (None for PushCommitQC); it counts +// toward AppQC leashes before prune. Epoch 0 has no prior leash. +// See epoch.Registry tip-interlock docs. +func (s *State) waitCommitEpochLeashes( + ctx context.Context, + epochIdx types.EpochIndex, + closingEpoch bool, + incoming utils.Option[*types.AppQC], +) error { if epochIdx == 0 { return nil } - if err := s.waitForAppQCEpoch(ctx, epochIdx-1); err != nil { + // AppQC of N-1 before any CommitQC in N. + appNeed := epochIdx - 1 + // Closing N drops Prev (N-1) from the duo — require AppQC in N so N-1 is + // fully pruned before the window slides (two-epoch avail invariant). + if closingEpoch { + appNeed = epochIdx + } + if err := s.waitForAppQCEpoch(ctx, appNeed, incoming); err != nil { return err } // N+1 existing is the unlock signal (usually from AdvanceIfNeeded). @@ -395,7 +415,8 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { } // Gate on the verified epoch; don't blindly trust qc.Proposal().EpochIndex(). // Wait — do not drop (caller may not retry). - if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex()); err != nil { + closing := idx+1 == ep.RoadRange().Next + if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex(), closing, utils.None[*types.AppQC]()); err != nil { return err } @@ -506,7 +527,9 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return fmt.Errorf("commitQC.Verify(): %w", err) } // Same leashes as PushCommitQC: tipcut pushBack is a CommitQC insert path. - if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex()); err != nil { + // Pass this AppQC as incoming so a tipcut that first enters epoch N can close N. + closing := idx+1 == ep.RoadRange().Next + if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex(), closing, utils.Some(appQC)); err != nil { return err } // Tipcut insert of a boundary CommitQC must slide Current like PushCommitQC. @@ -703,8 +726,10 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc // fullCommitQC returns the FullCommitQC for road n and the signing epoch. // Returns data.ErrPruned when the CommitQC/headers were pruned, or when the // road is behind the epoch duo (ErrRoadBeforeWindow) so the export loop can -// jump ahead — same as a pruned CommitQC. A road ahead of the duo is unexpected -// for an already-admitted CommitQC and hard-fails (no wait). +// jump ahead. Under the two-epoch invariant (boundary CommitQC of E requires +// AppQC in E), before-window roads are already pruned — same as a pruned +// CommitQC. A road ahead of the duo is unexpected for an already-admitted +// CommitQC and hard-fails (no wait). func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { qc, err := s.CommitQC(ctx, n) if err != nil { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 7868cd0419..19c6547fc3 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -403,7 +403,7 @@ func TestWaitForAppQCEpoch(t *testing.T) { timeout, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() - require.ErrorIs(t, state.waitForAppQCEpoch(timeout, 0), context.DeadlineExceeded) + require.ErrorIs(t, state.waitForAppQCEpoch(timeout, 0, utils.None[*types.AppQC]()), context.DeadlineExceeded) lane := keys[0].Public() b, err := state.ProduceLocalBlock(state.NextBlock(lane), types.GenPayload(rng)) @@ -420,14 +420,14 @@ func TestWaitForAppQCEpoch(t *testing.T) { qc0.GlobalRange().Next-1, 0, types.GenAppHash(rng), 0))) done := make(chan error, 1) - go func() { done <- state.waitForAppQCEpoch(ctx, 0) }() + go func() { done <- state.waitForAppQCEpoch(ctx, 0, utils.None[*types.AppQC]()) }() require.NoError(t, state.PushAppQC(ctx, appQC, qc0)) require.NoError(t, <-done) - require.NoError(t, state.waitForAppQCEpoch(ctx, 0)) + require.NoError(t, state.waitForAppQCEpoch(ctx, 0, utils.None[*types.AppQC]())) timeout2, cancel2 := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel2() - require.ErrorIs(t, state.waitForAppQCEpoch(timeout2, 1), context.DeadlineExceeded) + require.ErrorIs(t, state.waitForAppQCEpoch(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) } func TestPushBlockRejectsBadParentHash(t *testing.T) { @@ -1024,6 +1024,8 @@ func TestPushCommitQCStaleDrops(t *testing.T) { // TestFullCommitQCBeforeWindowIsPruned: CommitQC still held but duo has moved // past its road (ErrRoadBeforeWindow) — ErrPruned so the export loop can jump. +// Live admit should not leave unpruned before-window roads (boundary needs +// AppQC in E); this force-slides the duo to exercise the mapping. func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) @@ -1200,6 +1202,129 @@ func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { } } +// TestPushCommitQCBoundaryWaitsForAppQCInEpoch: last CommitQC of epoch E drops +// Prev (E-1); require AppQC in E so E-1 is pruned before the window slides. +func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + registry.AdvanceIfNeeded(epoch.LastRoad(0)) // epoch 2 for N+1 leash + + lane := keys[0].Public() + block0 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block0.Header()))}, + utils.None[*types.AppQC]()) + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.LastRoad(1) - 1, + }))), + }) + blockLast := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qcLast := makeCommitQC(ep1, keys, utils.Some(prevOnLast), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockLast.Header()))}, + utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(1), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC0) // only E-1 — not enough to close E + inner.commitQCs.first = epoch.LastRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) + } + state.markCommitQCsPersisted(prevOnLast) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushCommitQC(ctx, qcLast), context.Canceled) + + // AppQC in epoch 1 unlocks the boundary. + block1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc1 := makeCommitQC(ep1, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0), + }))), + })), map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block1.Header()))}, + utils.None[*types.AppQC]()) + appQC1 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc1.GlobalRange().First, qc1.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC1) + // Prune floor in E as live prune would after AppQC in E. + inner.commitQCs.first = epoch.FirstRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) + } + + require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(1)+1, inner.commitQCs.next) + require.Equal(t, types.EpochIndex(2), inner.epochDuo.Load().Current.EpochIndex()) + } +} + +// TestPushAppQCBoundaryIncomingAppQC: tipcut closing E may carry the first +// AppQC in E; count it as incoming before prune so catch-up does not deadlock. +func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + + lane := keys[0].Public() + block0 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block0.Header()))}, + utils.None[*types.AppQC]()) + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.LastRoad(1) - 1, + }))), + }) + blockLast := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qcLast := makeCommitQC(ep1, keys, utils.Some(prevOnLast), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockLast.Header()))}, + utils.None[*types.AppQC]()) + appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC0) // stale; incoming is appQCLast + inner.commitQCs.first = epoch.LastRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) + } + state.markCommitQCsPersisted(prevOnLast) + + require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(1)+1, inner.commitQCs.next) + require.Equal(t, types.EpochIndex(2), inner.epochDuo.Load().Current.EpochIndex()) + } +} + func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { registry, keys := epoch.GenRegistryAt(utils.TestRng(), 4, 0) // {0,1}; Current starts at epoch 0 ep0 := utils.OrPanic1(registry.EpochAt(0)) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 73994e4f32..16673b4188 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -50,11 +50,18 @@ type registryState struct { // exist, so commit cannot enter N until N-1 has finished. // - Do not soft-prune, skip roads, or invent epochs to "fix" tip skew. // -// Two leashes on every avail CommitQC insert for epoch N > 0 — PushCommitQC -// and PushAppQC tipcut pushBack (AppQC/CommitQC arrive on separate streams): +// Avail keeps a two-epoch operating window {E-1, E}. CommitQCs span a suffix of +// E-1 and a prefix of E; AppQC is the prune floor. The window must not drop +// E-1 until E-1 is fully pruned — otherwise FullCommitQC export would skip +// still-queued roads (data/ cannot gap). // -// - AppQC of N-1 — network/prune (CommitQC WAL ~≤1 epoch); wait for AppQC. -// - Registry has epoch N+1 — wait via WaitForDuo (same catch-up idea as AppQC). +// Leashes on every avail CommitQC insert for epoch N > 0 — PushCommitQC and +// PushAppQC tipcut pushBack (AppQC/CommitQC arrive on separate streams): +// +// - AppQC of N-1 — before any CommitQC in N (network/prune; WAL ~≤1 epoch). +// - AppQC of N — before the last CommitQC of N (duo would drop N-1; require +// N-1 fully pruned first). Epoch 0 has no prior epoch to drop. +// - Registry has epoch N+1 — wait via WaitForDuo (execution unlock). // Gate on epoch existence, not PushAppHash / local-exec cursors. // // Restart: @@ -66,7 +73,8 @@ type registryState struct { // - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for // load/verify and WaitForDuo, not a substitute for the interlock. // - FullCommitQC export: ErrRoadBeforeWindow → data.ErrPruned (caller jumps -// ahead, same as pruned CommitQC); ErrRoadAfterWindow hard-fails (no wait). +// ahead). Safe because the boundary AppQC-of-N leash ensures before-window +// roads are already pruned; ErrRoadAfterWindow hard-fails (no wait). type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. From f43df2ff92d390566a9f4eedd8b52d301d76d108 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 19 Jul 2026 13:52:41 -0700 Subject: [PATCH 87/98] refactor(autobahn): drop mid-epoch AppQC-of-N-1 admit wait Sealing N-1 already requires AppQC in N-1, so mid-N CommitQC admits only need registry N+1; keep the AppQC-of-N wait on epoch close for duo prune safety. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 14 ++++++------- .../internal/autobahn/avail/state_test.go | 20 ++----------------- .../internal/autobahn/epoch/registry.go | 5 +++-- 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 96c1fd7a27..e3f027a49f 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -325,7 +325,7 @@ func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex // CommitQC for epoch N > 0 (PushCommitQC and PushAppQC tipcut). closingEpoch // is true when this QC is the last road of its epoch (duo will drop Prev). // incoming is the AppQC on a PushAppQC call (None for PushCommitQC); it counts -// toward AppQC leashes before prune. Epoch 0 has no prior leash. +// toward the seal AppQC wait before prune. Epoch 0 has no prior leash. // See epoch.Registry tip-interlock docs. func (s *State) waitCommitEpochLeashes( ctx context.Context, @@ -336,15 +336,13 @@ func (s *State) waitCommitEpochLeashes( if epochIdx == 0 { return nil } - // AppQC of N-1 before any CommitQC in N. - appNeed := epochIdx - 1 // Closing N drops Prev (N-1) from the duo — require AppQC in N so N-1 is - // fully pruned before the window slides (two-epoch avail invariant). + // fully pruned before the window slides. Mid-N admits skip an AppQC-of-N-1 + // wait: sealing N-1 already required AppQC in N-1. if closingEpoch { - appNeed = epochIdx - } - if err := s.waitForAppQCEpoch(ctx, appNeed, incoming); err != nil { - return err + if err := s.waitForAppQCEpoch(ctx, epochIdx, incoming); err != nil { + return err + } } // N+1 existing is the unlock signal (usually from AdvanceIfNeeded). _, err := s.data.Registry().WaitForDuo(ctx, epoch.FirstRoad(epochIdx+1)) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 19c6547fc3..030730fdb5 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1087,8 +1087,8 @@ func TestFullCommitQCAfterWindowHardFails(t *testing.T) { require.NotErrorIs(t, err, data.ErrPruned) } -// TestPushCommitQCWaitsForEpochUnlock: AppQC of N-1 is present but registry -// lacks epoch N+1 — PushCommitQC waits for N+1, does not drop. +// TestPushCommitQCWaitsForEpochUnlock: registry lacks epoch N+1 — PushCommitQC +// waits for N+1, does not drop. (Mid-N no longer waits on AppQC of N-1.) func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; epoch 2 absent @@ -1105,13 +1105,6 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { registerDuoAtEpoch(state, 1) lane := keys[0].Public() - block0 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) - qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), - map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block0.Header()))}, - utils.None[*types.AppQC]()) - appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) - prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ EpochIndex: 0, @@ -1125,7 +1118,6 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { require.Equal(t, epoch.FirstRoad(1), qc1.Proposal().Index()) for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQC0) inner.commitQCs.first = epoch.FirstRoad(1) inner.commitQCs.next = epoch.FirstRoad(1) } @@ -1162,13 +1154,6 @@ func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { registerDuoAtEpoch(state, 1) lane := keys[0].Public() - block0 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) - qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), - map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block0.Header()))}, - utils.None[*types.AppQC]()) - appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) - prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ EpochIndex: 0, @@ -1184,7 +1169,6 @@ func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { require.Equal(t, epoch.FirstRoad(1), qc1.Proposal().Index()) for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQC0) inner.commitQCs.first = epoch.FirstRoad(1) inner.commitQCs.next = epoch.FirstRoad(1) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 16673b4188..d3e35b531f 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -58,9 +58,10 @@ type registryState struct { // Leashes on every avail CommitQC insert for epoch N > 0 — PushCommitQC and // PushAppQC tipcut pushBack (AppQC/CommitQC arrive on separate streams): // -// - AppQC of N-1 — before any CommitQC in N (network/prune; WAL ~≤1 epoch). // - AppQC of N — before the last CommitQC of N (duo would drop N-1; require -// N-1 fully pruned first). Epoch 0 has no prior epoch to drop. +// N-1 fully pruned first). Mid-N admits do not re-check AppQC of N-1: the +// prior seal of N-1 already required AppQC in N-1. Epoch 0 has no prior +// epoch to drop. // - Registry has epoch N+1 — wait via WaitForDuo (execution unlock). // Gate on epoch existence, not PushAppHash / local-exec cursors. // From 5e1db109d948f1782a79ce3d450d84a1f8f972cb Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 19 Jul 2026 16:43:14 -0700 Subject: [PATCH 88/98] refactor(autobahn): gate registry N+1 only when sealing epoch N Mid-N CommitQC admits only need committee N; wait for registry N+1 (and AppQC in N) only on the last road, when Current would advance into N+1. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 20 ++-- .../internal/autobahn/avail/state_test.go | 104 +++++++++++++----- .../internal/autobahn/epoch/registry.go | 19 ++-- 3 files changed, 96 insertions(+), 47 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index e3f027a49f..fa5f5cadff 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -322,8 +322,9 @@ func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex } // waitCommitEpochLeashes enforces tip-interlock gates before inserting a -// CommitQC for epoch N > 0 (PushCommitQC and PushAppQC tipcut). closingEpoch -// is true when this QC is the last road of its epoch (duo will drop Prev). +// CommitQC that seals epoch N > 0 (last road of N). Mid-N admits are not gated +// here — committee N is already in the duo. closingEpoch is true when this QC +// is the last road of its epoch (duo will advance into N+1 and drop Prev). // incoming is the AppQC on a PushAppQC call (None for PushCommitQC); it counts // toward the seal AppQC wait before prune. Epoch 0 has no prior leash. // See epoch.Registry tip-interlock docs. @@ -333,18 +334,15 @@ func (s *State) waitCommitEpochLeashes( closingEpoch bool, incoming utils.Option[*types.AppQC], ) error { - if epochIdx == 0 { + if epochIdx == 0 || !closingEpoch { return nil } - // Closing N drops Prev (N-1) from the duo — require AppQC in N so N-1 is - // fully pruned before the window slides. Mid-N admits skip an AppQC-of-N-1 - // wait: sealing N-1 already required AppQC in N-1. - if closingEpoch { - if err := s.waitForAppQCEpoch(ctx, epochIdx, incoming); err != nil { - return err - } + // Sealing N drops Prev (N-1) and advances Current into N+1. + // AppQC in N: N-1 fully pruned before it leaves the duo. + if err := s.waitForAppQCEpoch(ctx, epochIdx, incoming); err != nil { + return err } - // N+1 existing is the unlock signal (usually from AdvanceIfNeeded). + // N+1 existing: execution finished N-1 (usually AdvanceIfNeeded). _, err := s.data.Registry().WaitForDuo(ctx, epoch.FirstRoad(epochIdx+1)) return err } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 030730fdb5..04ea7ebcf4 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1087,9 +1087,9 @@ func TestFullCommitQCAfterWindowHardFails(t *testing.T) { require.NotErrorIs(t, err, data.ErrPruned) } -// TestPushCommitQCWaitsForEpochUnlock: registry lacks epoch N+1 — PushCommitQC -// waits for N+1, does not drop. (Mid-N no longer waits on AppQC of N-1.) -func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { +// TestPushCommitQCMidEpochNoExecLeash: mid-N CommitQC admits without registry +// N+1 (execution may still be in N-1). Only sealing N waits on N+1. +func TestPushCommitQCMidEpochNoExecLeash(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; epoch 2 absent ep0 := utils.OrPanic1(registry.EpochAt(0)) @@ -1123,22 +1123,14 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { } state.markCommitQCsPersisted(prevTip) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - require.ErrorIs(t, state.PushCommitQC(ctx, qc1), context.Canceled) - - // Unlock epoch 2 (as AdvanceIfNeeded after last road of epoch 0), then admit. - registry.AdvanceIfNeeded(epoch.LastRoad(0)) require.NoError(t, state.PushCommitQC(t.Context(), qc1)) for inner := range state.inner.Lock() { require.Equal(t, epoch.FirstRoad(1)+1, inner.commitQCs.next) - require.NotNil(t, inner.commitQCs.q[epoch.FirstRoad(1)]) } } -// TestPushAppQCWaitsForEpochUnlock: tipcut CommitQC insert via PushAppQC must -// honor the same N+1 registry leash as PushCommitQC (separate AppQC stream). -func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { +// TestPushCommitQCWaitsForEpochUnlock: sealing N without registry N+1 waits. +func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; epoch 2 absent ep0 := utils.OrPanic1(registry.EpochAt(0)) @@ -1154,35 +1146,95 @@ func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { registerDuoAtEpoch(state, 1) lane := keys[0].Public() - prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + // Seal needs AppQC in E as well as registry N+1. + blockMid := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qcMid := makeCommitQC(ep1, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ EpochIndex: 0, Index: epoch.LastRoad(0), }))), - }) - block1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) - qc1 := makeCommitQC(ep1, keys, utils.Some(prevTip), - map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block1.Header()))}, + })), map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockMid.Header()))}, utils.None[*types.AppQC]()) appQC1 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qc1.GlobalRange().First, qc1.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) - require.Equal(t, epoch.FirstRoad(1), qc1.Proposal().Index()) + qcMid.GlobalRange().First, qcMid.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.LastRoad(1) - 1, + }))), + }) + blockLast := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qcLast := makeCommitQC(ep1, keys, utils.Some(prevOnLast), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockLast.Header()))}, + utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(1), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC1) inner.commitQCs.first = epoch.FirstRoad(1) - inner.commitQCs.next = epoch.FirstRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) } - state.markCommitQCsPersisted(prevTip) + state.markCommitQCsPersisted(prevOnLast) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushCommitQC(ctx, qcLast), context.Canceled) + + registry.AdvanceIfNeeded(epoch.LastRoad(0)) // seeds epoch 2 + require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(1)+1, inner.commitQCs.next) + require.Equal(t, types.EpochIndex(2), inner.epochDuo.Load().Current.EpochIndex()) + } +} + +// TestPushAppQCWaitsForEpochUnlock: tipcut seal of N waits on registry N+1. +func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; epoch 2 absent + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + _, err := registry.EpochAt(epoch.FirstRoad(2)) + require.Error(t, err, "test setup: epoch 2 must be absent") + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + + lane := keys[0].Public() + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.LastRoad(1) - 1, + }))), + }) + blockLast := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qcLast := makeCommitQC(ep1, keys, utils.Some(prevOnLast), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockLast.Header()))}, + utils.None[*types.AppQC]()) + appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + require.Equal(t, epoch.LastRoad(1), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commitQCs.first = epoch.LastRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) + } + state.markCommitQCsPersisted(prevOnLast) ctx, cancel := context.WithCancel(t.Context()) cancel() - require.ErrorIs(t, state.PushAppQC(ctx, appQC1, qc1), context.Canceled) + // Incoming AppQC satisfies seal AppQC leash; still waits on missing epoch 2. + require.ErrorIs(t, state.PushAppQC(ctx, appQCLast, qcLast), context.Canceled) registry.AdvanceIfNeeded(epoch.LastRoad(0)) - require.NoError(t, state.PushAppQC(t.Context(), appQC1, qc1)) + require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) for inner := range state.inner.Lock() { - require.Equal(t, epoch.FirstRoad(1)+1, inner.commitQCs.next) - require.NotNil(t, inner.commitQCs.q[epoch.FirstRoad(1)]) + require.Equal(t, epoch.LastRoad(1)+1, inner.commitQCs.next) + require.Equal(t, types.EpochIndex(2), inner.epochDuo.Load().Current.EpochIndex()) } } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index d3e35b531f..78218cdafe 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -46,8 +46,8 @@ type registryState struct { // - Execution cannot pass commit. // - Epoch unlock is coarse: last road of N-1 registers epoch N+1 // (AdvanceIfNeeded). Avail does not track per-road exec progress — it only -// gates when admitting CommitQCs whose epoch is N (>0): N+1 must already -// exist, so commit cannot enter N until N-1 has finished. +// gates when sealing epoch N (last CommitQC of N): N+1 must already exist, +// so commit cannot enter N+1 until N-1 has finished. // - Do not soft-prune, skip roads, or invent epochs to "fix" tip skew. // // Avail keeps a two-epoch operating window {E-1, E}. CommitQCs span a suffix of @@ -55,15 +55,14 @@ type registryState struct { // E-1 until E-1 is fully pruned — otherwise FullCommitQC export would skip // still-queued roads (data/ cannot gap). // -// Leashes on every avail CommitQC insert for epoch N > 0 — PushCommitQC and -// PushAppQC tipcut pushBack (AppQC/CommitQC arrive on separate streams): +// Leashes on avail CommitQC insert when sealing epoch N > 0 (last road of N) — +// PushCommitQC and PushAppQC tipcut pushBack (AppQC/CommitQC arrive on separate +// streams). Mid-N admits need only the Current window / committee N: // -// - AppQC of N — before the last CommitQC of N (duo would drop N-1; require -// N-1 fully pruned first). Mid-N admits do not re-check AppQC of N-1: the -// prior seal of N-1 already required AppQC in N-1. Epoch 0 has no prior -// epoch to drop. -// - Registry has epoch N+1 — wait via WaitForDuo (execution unlock). -// Gate on epoch existence, not PushAppHash / local-exec cursors. +// - AppQC of N — duo would drop N-1; require N-1 fully pruned first. +// - Registry has epoch N+1 — wait via WaitForDuo (execution unlock before +// Current advances into N+1). Gate on epoch existence, not PushAppHash / +// local-exec cursors. Epoch 0 has no prior epoch to drop / unlock. // // Restart: // From c0911225b0fa97f98cb56e8a37c479c9036f2f71 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 19 Jul 2026 17:50:26 -0700 Subject: [PATCH 89/98] fix(autobahn): seed N+2 on empty closing tipcut via PushQC Empty LastRoad tipcuts produce no globals for executeBlock, so AdvanceIfNeeded never ran and sealing the next epoch could stall on WaitForDuo. Unlock from data.PushQC when an empty closing QC is applied. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/state.go | 5 +++++ sei-tendermint/internal/autobahn/data/state_test.go | 7 +++++++ sei-tendermint/internal/autobahn/epoch/registry.go | 7 ++++--- sei-tendermint/internal/p2p/giga_router_common.go | 5 +++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 8d92397010..d28b4c3476 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -479,6 +479,11 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty inner.epochDuo.Store(*nextDuo) } ctrl.Updated() + // Empty closing tipcut: no globals for runExecute to process, so + // seed N+2 here (non-empty roads unlock on last global in executeBlock). + if applied && gr.First == gr.Next { + s.cfg.Registry.AdvanceIfNeeded(idx) + } } if len(byHash) == 0 { break diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index c834463f89..38bf63d776 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -1147,6 +1147,8 @@ func TestPushQC_AdvancesEpochDuoAtBoundary(t *testing.T) { ep0 := state.epochDuo.Load().Current require.Equal(t, types.EpochIndex(0), ep0.EpochIndex()) + _, err := registry.EpochAt(epoch.FirstRoad(2)) + require.Error(t, err, "epoch 2 must be absent before empty closing tipcut") // Build a CommitQC at the last road of epoch 0 (no lane blocks needed). view := types.View{EpochIndex: ep0.EpochIndex(), Index: epoch.LastRoad(0)} @@ -1156,11 +1158,16 @@ func TestPushQC_AdvancesEpochDuoAtBoundary(t *testing.T) { vs[i] = types.Sign(k, vote) } qc := types.NewFullCommitQC(types.NewCommitQC(vs), nil) + require.Equal(t, qc.QC().GlobalRange().First, qc.QC().GlobalRange().Next, "empty tipcut") require.NoError(t, state.PushQC(t.Context(), qc, nil)) if got := state.epochDuo.Load().Current.EpochIndex(); got != 1 { t.Fatalf("epochDuo.Current after epoch boundary = %d, want 1", got) } + // Empty closing tipcut must still seed N+2 (no executeBlock to do it). + if _, err := registry.EpochAt(epoch.FirstRoad(2)); err != nil { + t.Fatalf("epoch 2 after empty LastRoad(0) PushQC: %v", err) + } }) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 78218cdafe..2976550774 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -263,9 +263,10 @@ func (r *Registry) EnsureAfterExecuted(road types.RoadIndex) { } } -// AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is executed -// (callers should invoke only on that road's final global block). Earlier roads -// in the epoch are a no-op. Restart uses EnsureAfterExecuted / EnsureDuoAt +// AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is +// execution-complete. Call after the last global of that road is executed, or +// immediately when the closing tipcut is empty (no globals to run). Earlier +// roads in the epoch are a no-op. Restart uses EnsureAfterExecuted / EnsureDuoAt // instead of replaying this with synthetic LastRoad tips. // Committee for N+2 is currently the genesis committee. // TODO: pass the real N+2 committee once execution derives it. diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 6ea55ad31a..4b679fb1f0 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -299,9 +299,10 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo if err := r.data.PushAppHash(ctx, b.GlobalNumber, resp.AppHash); err != nil { return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } - // Seed N+2 only when the last block of an epoch's closing road is executed. + // Seed N+2 when the last global of an epoch's closing road is executed. // A CommitQC can span multiple globals; calling AdvanceIfNeeded on the first - // block of LastRoad(N) would unlock N+2 too early. + // block of LastRoad(N) would unlock N+2 too early. Empty closing tipcuts + // (no globals) unlock instead from data.PushQC when the QC is applied. // TODO: real N+2 committee once execution derives it. qc, err := r.data.QC(ctx, b.GlobalNumber) if err != nil { From 27da76a785952b06f657a6d5eb0e57d05f94ce42 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 12:26:55 -0700 Subject: [PATCH 90/98] fix(autobahn): hard-fail restart tip skew across data/avail/consensus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check avail≥consensus inside consensus.NewState and consensus≥data in the validator router after construction, so layers stay independent at build time. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 13 ++++++++-- .../internal/autobahn/consensus/inner.go | 8 +++--- .../internal/autobahn/consensus/state.go | 15 +++++++++++ .../internal/autobahn/consensus/state_test.go | 26 +++++++++++++++++++ .../internal/autobahn/data/state.go | 16 ++++++++++++ .../internal/autobahn/epoch/registry.go | 7 ++--- .../internal/p2p/giga_router_validator.go | 19 ++++++++++++++ .../p2p/giga_router_validator_test.go | 8 ++++++ 8 files changed, 104 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index fa5f5cadff..7ba787f7cc 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -159,8 +159,9 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin } // Operating duo is the CommitQC tipcut (not the prune-anchor road). - // Epoch seeding is owned by data.NewState (SetupInitialDuo); this tip - // must fall in that window (avail may not lead data across an unseeded epoch). + // Epoch seeding is owned by data.NewState (SetupInitialDuo); DuoAt hard-fails + // if this tip needs an unseeded epoch. Avail vs consensus tip ordering is + // checked in consensus.NewState; consensus vs data in p2p.checkRestartTips. // newInner re-verifies loaded CommitQCs so consensus can trust latestCommitQC. commitTip := types.RoadIndex(0) if ls, ok := loaded.Get(); ok { @@ -206,6 +207,14 @@ func (s *State) FirstCommitQC() types.RoadIndex { panic("unreachable") } +// CommitTipCut is the next CommitQC road after restore/admit (commitQCs.next). +func (s *State) CommitTipCut() types.RoadIndex { + for inner := range s.inner.Lock() { + return inner.commitQCs.next + } + panic("unreachable") +} + // Data returns the data state. func (s *State) Data() *data.State { return s.data diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 900af4c1cf..01410eccb2 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -107,7 +107,8 @@ func (i inner) View() types.View { // newInner creates the inner state from persisted data loaded by NewPersister. // data is None on fresh start (persistence disabled or no prior state). -// Returns error if persisted state is corrupt (see persistedInner.validate). +// Returns error if persisted state is corrupt (see persistedInner.validate) or +// required epochs are missing from the registry. func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) (inner, error) { var persisted persistedInner @@ -120,8 +121,9 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( } // View epoch = tipcut road; CommitQC may still be the prior epoch's last road. - // Epoch seeding is owned by data.NewState (SetupInitialDuo); consensus tip - // must fall in that window (may not lead data across an unseeded epoch). + // Epoch seeding is owned by data.NewState (SetupInitialDuo); EpochAt hard-fails + // if this tip needs an unseeded epoch. Tip ordering vs avail is checked in + // NewState; vs data in p2p.checkRestartTips. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 2f5d0670ae..da81be3c52 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -2,6 +2,7 @@ package consensus import ( "context" + "errors" "fmt" "time" @@ -134,9 +135,23 @@ func newState( myTimeoutVote: utils.NewAtomicSend(utils.None[*types.FullTimeoutVote]()), myTimeoutQC: utils.NewAtomicSend(utils.None[*types.TimeoutQC]()), } + // Avail admits CommitQCs before consensus tip catches up via LastCommitQC. + // Restart: avail tipcut must be >= consensus tipcut. + if availTip, consTip := availState.CommitTipCut(), s.CommitTipCut(); availTip < consTip { + return nil, fmt.Errorf("%w: avail tipcut %d < consensus tipcut %d", ErrAvailBehindConsensus, availTip, consTip) + } return s, nil } +// ErrAvailBehindConsensus is returned on restart when avail's CommitQC tipcut is +// strictly behind consensus's (insane: consensus tip advances from avail). +var ErrAvailBehindConsensus = errors.New("avail CommitQC tip behind consensus tip") + +// CommitTipCut is the consensus view tipcut (NextIndexOpt of persisted CommitQC). +func (s *State) CommitTipCut() types.RoadIndex { + return s.innerRecv.Load().View().Index +} + func (s *State) timeoutQC() utils.AtomicRecv[utils.Option[*types.TimeoutQC]] { for tv := range s.timeoutVotes.Lock() { return tv.qc.Subscribe() diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index dbc741edfe..f9a5485024 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -7,8 +7,10 @@ import ( "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -327,3 +329,27 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { }) require.NoError(t, err) } + +func TestNewState_AvailBehindConsensus(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 3) + ds := utils.OrPanic1(data.NewState( + &data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), + )) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + proposal := types.GenProposalForEpoch(rng, ep0, types.View{Index: 0, Number: 0}) + votes := make([]*types.Signed[*types.CommitVote], len(keys)) + for i, k := range keys { + votes[i] = types.Sign(k, types.NewCommitVote(proposal)) + } + // Consensus tipcut 1, fresh avail tipcut 0 → avail behind consensus. + _, err := newState(&Config{ + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + PersistentStateDir: utils.None[string](), + }, ds, utils.None[persist.Persister[*pb.PersistedInner]](), utils.Some(innerProtoConv.Encode(&persistedInner{ + CommitQC: utils.Some(types.NewCommitQC(votes)), + }))) + require.ErrorIs(t, err, ErrAvailBehindConsensus) +} diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index d28b4c3476..a50c53c7a9 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -414,6 +414,22 @@ func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } // EpochDuo is a point-in-time snapshot; re-call after a boundary advance. func (s *State) EpochDuo() types.EpochDuo { return s.epochDuo.Load() } +// CommitTipCut is the road after the last applied CommitQC (Index+1), or 0 if +// none. Restart anchor for p2p.checkRestartTips (consensus tip vs data). +func (s *State) CommitTipCut() types.RoadIndex { + for inner := range s.inner.Lock() { + if inner.nextQC == 0 || inner.nextQC <= inner.first { + return 0 + } + qc := inner.qcs[inner.nextQC-1] + if qc == nil { + return 0 + } + return qc.QC().Proposal().Index() + 1 + } + panic("unreachable") +} + // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 2976550774..c9e7a9bc2c 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -67,9 +67,10 @@ type registryState struct { // Restart: // // - data/ is the anchor (SetupInitialDuo only from data.NewState). -// - Avail/consensus must not lead data across an unseeded epoch. -// - Holes or a full-epoch lead → hard-fail (data-sync / wipe avail+consensus), -// not soft-heal. +// - After construction: consensus checks avail tipcut >= consensus tipcut; +// the giga validator router checks consensus tipcut >= data.CommitTipCut() +// (together ⇒ avail >= data). Behind → hard-fail. +// - Lead into an unseeded epoch → EpochAt/DuoAt hard-fail (no soft-heal). // - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for // load/verify and WaitForDuo, not a substitute for the interlock. // - FullCommitQC export: ErrRoadBeforeWindow → data.ErrPruned (caller jumps diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 98d0dbd5ad..3176f2aca7 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -2,6 +2,7 @@ package p2p import ( "context" + "errors" "fmt" "net/url" @@ -14,6 +15,21 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) +// ErrTipBehindData is returned on restart when consensus CommitQC tipcut is +// strictly behind data's. Combined with consensus's avail≥consensus check, +// this implies avail≥data (data QCs are exported from avail). +var ErrTipBehindData = errors.New("CommitQC tip behind data tip") + +// checkRestartTips ensures consensus tipcut is not behind data. +// Call after data + consensus are constructed. Avail vs consensus is checked +// inside consensus.NewState. +func checkRestartTips(dataTip, consensusTip atypes.RoadIndex) error { + if consensusTip < dataTip { + return fmt.Errorf("%w: consensus tipcut %d < data tipcut %d", ErrTipBehindData, consensusTip, dataTip) + } + return nil +} + type gigaValidatorRouter struct { *gigaRouterCommon @@ -37,6 +53,9 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV if err != nil { return nil, fmt.Errorf("consensus.NewState(): %w", err) } + if err := checkRestartTips(dataState.CommitTipCut(), consensusState.CommitTipCut()); err != nil { + return nil, err + } producerState := producer.NewState(cfg.Producer, consensusState, cfg.App) logger.Info("GigaRouter initialized (validator)", "validators", len(cfg.ValidatorAddrs), "dial_interval", cfg.DialInterval, "inbound_fullnode_cap", cfg.MaxInboundFullnodePeers) return &gigaValidatorRouter{ diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index c0ecc81910..41eba99d14 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -282,3 +282,11 @@ func TestGigaRouter_EvmProxy(t *testing.T) { require.Equal(t, expectedRemoteURLs, returnedRemoteURLs) } + +func TestCheckRestartTips(t *testing.T) { + require.NoError(t, checkRestartTips(0, 0)) + require.NoError(t, checkRestartTips(1, 1)) + require.NoError(t, checkRestartTips(1, 2), "consensus lead is fine") + require.ErrorIs(t, checkRestartTips(2, 1), ErrTipBehindData) + require.ErrorIs(t, checkRestartTips(2, 0), ErrTipBehindData) +} From 83c112c874d61d25087d8e3bee8c2e81538b39f9 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 12:55:41 -0700 Subject: [PATCH 91/98] Reject empty tipcuts in Proposal.Verify and NewProposal. Leaders already wait via WaitForLaneQCs; verification must refuse proposals that finalize no blocks so empty tipcuts cannot enter the QC chain. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 12 +- .../autobahn/types/proposal_test.go | 106 +++++++++++------- sei-tendermint/autobahn/types/testonly.go | 11 +- 3 files changed, 80 insertions(+), 49 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 2206e65e8b..bb78f668c1 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -231,12 +231,16 @@ func (m *Proposal) NextTimestamp() time.Time { } // Verify checks epoch binding, lane-range structural validity (bounds, max-length, -// and lane committee membership), and proposal-hash integrity. QC-chain continuity +// and lane committee membership). Empty tipcuts (no finalized blocks) are rejected; +// leaders wait for LaneQCs via WaitForLaneQCs instead. QC-chain continuity // (matching starts against the previous QC) is only enforced by FullProposal.Verify. func (m *Proposal) Verify(ep *Epoch) error { if err := m.view.Verify(ep); err != nil { return fmt.Errorf("view: %w", err) } + if m.globalRange.Len() == 0 { + return errors.New("empty tipcut: proposal must finalize at least one block") + } c := ep.Committee() for _, r := range m.laneRanges { if got := r.Len(); got > MaxLaneRangeInProposal { @@ -356,7 +360,11 @@ func buildProposal( if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { timestamp = wantMin } - return newProposal(viewSpec.View(), timestamp, laneRanges, app, viewSpec.NextGlobalBlock()), appQC, nil + proposal := newProposal(viewSpec.View(), timestamp, laneRanges, app, viewSpec.NextGlobalBlock()) + if proposal.GlobalRange().Len() == 0 { + return nil, appQC, errors.New("empty tipcut: need at least one LaneQC") + } + return proposal, appQC, nil } // NewProposalForTesting builds a FullProposal exactly like NewProposal but attaches the diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 408ed5f9d6..8f5ce7404e 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -71,15 +71,26 @@ func makeAppQCFor(keys []SecretKey, globalNum GlobalBlockNumber, roadIdx RoadInd return NewAppQC(votes) } -func TestProposalVerifyFreshEmptyRanges(t *testing.T) { +func TestProposalVerifyRejectsEmptyTipcut(t *testing.T) { rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) + committee, _ := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) vs := ViewSpec{Epoch: ep} - proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) - require.NoError(t, fp.Verify(vs)) + // Direct Proposal.Verify rejects empty tipcuts (no LaneQCs / zero GlobalRange). + // Local propose waits via WaitForLaneQCs; verification must refuse empty tipcuts + // from peers as well. + empty := newProposal(vs.View(), time.Now(), nil, utils.None[*AppProposal](), vs.NextGlobalBlock()) + require.Equal(t, uint64(0), empty.GlobalRange().Len()) + require.Error(t, empty.Verify(ep)) +} + +// oneLaneQCMap builds a single LaneQC so NewProposal is non-empty (empty tipcuts +// are rejected by Proposal.Verify). +func oneLaneQCMap(rng utils.Rng, committee *Committee, keys []SecretKey, vs ViewSpec) map[LaneID]*LaneQC { + lane := committee.Lanes().At(0) + n := LaneRangeOpt(vs.CommitQC, lane).Next() + return map[LaneID]*LaneQC{lane: makeLaneQC(rng, committee, keys, lane, n, BlockHeaderHash{})} } func TestProposalVerifyFreshWithBlocks(t *testing.T) { @@ -172,7 +183,7 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), genesisTimestamp, committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) vs := ViewSpec{Epoch: ep} k := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) require.NoError(t, fp.Verify(vs)) vsLater := vs @@ -209,7 +220,7 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { fp1a := utils.OrPanic1(NewProposal( proposer1, vs1a, fp0a.Proposal().Msg().NextTimestamp(), - nil, + oneLaneQCMap(rng, committee, keys, vs1a), utils.None[*AppQC](), )) @@ -226,7 +237,7 @@ func TestProposalVerifyRejectsViewMismatch(t *testing.T) { // Build a valid proposal at genesis view (0, 0). vs0 := ViewSpec{Epoch: ep} leader0 := leaderKey(committee, keys, vs0.View()) - fp := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) // Verify it against a different ViewSpec (view 1, 0). commitQC := makeCommitQCFromProposal(keys, fp) @@ -243,8 +254,8 @@ func TestProposalVerifyRejectsForgedSignature(t *testing.T) { proposerKey := leaderKey(committee, keys, vs.View()) // Build two valid proposals with different timestamps. - fp1 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) - fp2 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now().Add(time.Hour), nil, utils.None[*AppQC]())) + fp1 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) + fp2 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now().Add(time.Hour), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Graft fp1's signature onto fp2 (different content). fp2.proposal.sig = fp1.proposal.sig @@ -259,7 +270,7 @@ func TestProposalVerifyRejectsWrongProposer(t *testing.T) { vs := ViewSpec{Epoch: ep} correctLeader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Re-sign the same proposal with a different (non-leader) key. var wrongKey SecretKey @@ -286,7 +297,7 @@ func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { vs := ViewSpec{Epoch: ep} // no timeoutQC proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Attach a timeoutQC that the ViewSpec doesn't expect. var timeoutVotes []*FullTimeoutVote @@ -312,7 +323,7 @@ func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { vs := ViewSpec{Epoch: ep} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Replace one committee lane with a non-committee lane. // E.g. committee = {A, B, C, D}, proposal = {A, B, C, X}. @@ -353,24 +364,27 @@ func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { vs := ViewSpec{Epoch: ep} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) - // Drop one lane — the omitted lane gets an implicit [0, 0) range, - // which matches the expected first=0 at genesis. + // Drop one empty lane — the omitted lane gets an implicit [0, 0) range, + // which matches the expected first=0 at genesis. Keep the non-empty range + // (and its LaneQC) so the tipcut is non-empty. origP := fp.Proposal().Msg() var keptRanges []*LaneRange - first := true + droppedEmpty := false for _, r := range origP.laneRanges { - if first { - first = false + if !droppedEmpty && r.Len() == 0 { + droppedEmpty = true continue } keptRanges = append(keptRanges, r) } + require.True(t, droppedEmpty) shortProposal := newProposal(origP.view, origP.timestamp, keptRanges, origP.app, origP.GlobalRange().First) shortFP := &FullProposal{ proposal: Sign(proposerKey, shortProposal), + laneQCs: fp.laneQCs, } require.NoError(t, shortFP.Verify(vs)) } @@ -382,22 +396,27 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { vs := ViewSpec{Epoch: ep} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) - // Keep only every other lane (e.g. {A, C} out of {A, B, C, D}). + // Drop every other empty lane (keep the non-empty range and its LaneQC). origP := fp.Proposal().Msg() var keptRanges []*LaneRange - i := 0 + emptyIdx := 0 for _, r := range origP.laneRanges { - if i%2 == 0 { - keptRanges = append(keptRanges, r) + if r.Len() == 0 { + if emptyIdx%2 == 0 { + emptyIdx++ + continue + } + emptyIdx++ } - i++ + keptRanges = append(keptRanges, r) } shortProposal := newProposal(origP.view, origP.timestamp, keptRanges, origP.app, origP.GlobalRange().First) shortFP := &FullProposal{ proposal: Sign(proposerKey, shortProposal), + laneQCs: fp.laneQCs, } require.NoError(t, shortFP.Verify(vs)) } @@ -409,7 +428,7 @@ func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { vs := ViewSpec{Epoch: ep} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Tamper: change one lane's first to 5 (genesis expects 0). origP := fp.Proposal().Msg() @@ -571,13 +590,14 @@ func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { lQCs := map[LaneID]*LaneQC{l: makeLaneQC(rng, committee, keys, l, 0, GenBlockHeaderHash(rng))} commitQC0 := makeCommitQC(keys, makeFullProposal(ep, keys, utils.None[*CommitQC](), lQCs, utils.None[*AppQC]())) appQC0 := makeAppQCFor(keys, commitQC0.GlobalRange().First, 0, GenAppHash(rng), ep.EpochIndex()) - commitQC1a := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), nil, utils.Some(appQC0))) - commitQC1b := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), nil, utils.None[*AppQC]())) - fp2a := makeFullProposal(ep, keys, utils.Some(commitQC1a), nil, utils.None[*AppQC]()) - fp2b := makeFullProposal(ep, keys, utils.Some(commitQC1b), nil, utils.None[*AppQC]()) + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epoch: ep} + commitQC1a := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), oneLaneQCMap(rng, committee, keys, vs1), utils.Some(appQC0))) + commitQC1b := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), oneLaneQCMap(rng, committee, keys, vs1), utils.None[*AppQC]())) + vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epoch: ep} + fp2a := makeFullProposal(ep, keys, utils.Some(commitQC1a), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]()) + fp2b := makeFullProposal(ep, keys, utils.Some(commitQC1b), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]()) // We construct the invalid proposal by constructing 2 alternative futures: one with appQC, one without. - vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epoch: ep} require.NoError(t, fp2a.Verify(vs)) require.Error(t, fp2b.Verify(vs)) } @@ -589,7 +609,7 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { vs := ViewSpec{Epoch: ep} // no previous commitQC, so app starts at None leader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Attach an unrequested AppQC. appQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) @@ -612,7 +632,7 @@ func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { // Build a valid proposal with an AppQC, then strip it. goodAppQC := makeAppQCFor(keys, ep.FirstBlock()-1, 0, GenAppHash(rng), ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) tamperedFP := &FullProposal{ proposal: fp.proposal, @@ -630,7 +650,7 @@ func TestProposalVerifyRejectsAppQCMismatch(t *testing.T) { // Build a valid proposal with an AppQC, then swap in a different one. goodAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) differentAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) tamperedFP := &FullProposal{ @@ -660,11 +680,11 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { } // app epoch matches proposal epoch — accepted. - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(0)))) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(0)))) require.NoError(t, fp.Verify(vs)) // app epoch differs from proposal epoch — rejected. - fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(1)))) + fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(1)))) require.Error(t, fpWrong.Verify(vs)) } @@ -677,7 +697,7 @@ func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { appHash := GenAppHash(rng) goodAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, appHash, ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) // Swap in an AppQC signed by NON-committee keys (same hash). otherKeys := make([]SecretKey, len(keys)) @@ -751,7 +771,7 @@ func TestProposalVerifyValidReproposal(t *testing.T) { require.Equal(t, View{Index: 0, Number: 1, EpochIndex: ep.EpochIndex()}, vs1.View()) leader1 := leaderKey(committee, keys, vs1.View()) - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), oneLaneQCMap(rng, committee, keys, vs1), utils.None[*AppQC]())) // Reproposal must carry the same GlobalRange as the original. require.Equal(t, fp0.Proposal().Msg().GlobalRange(), reproposal.Proposal().Msg().GlobalRange()) @@ -766,7 +786,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { // Build a PrepareQC at (0, 0). vs0 := ViewSpec{Epoch: ep} leader0 := leaderKey(committee, keys, vs0.View()) - fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) + fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) var prepareVotes []*Signed[*PrepareVote] for _, k := range keys { @@ -784,7 +804,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { leader1 := leaderKey(committee, keys, vs1.View()) // Create a valid reproposal, then tamper it with unnecessary laneQCs. - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), oneLaneQCMap(rng, committee, keys, vs1), utils.None[*AppQC]())) lane := keys[0].Public() laneQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -805,7 +825,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { // Build a PrepareQC at (0, 0). vs0 := ViewSpec{Epoch: ep} leader0 := leaderKey(committee, keys, vs0.View()) - fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) + fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) var prepareVotes []*Signed[*PrepareVote] for _, k := range keys { @@ -823,7 +843,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { leader1 := leaderKey(committee, keys, vs1.View()) // Build the valid reproposal, then tamper its timestamp to get a different hash. - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), oneLaneQCMap(rng, committee, keys, vs1), utils.None[*AppQC]())) origP := reproposal.Proposal().Msg() var ranges []*LaneRange @@ -858,7 +878,7 @@ func TestProposalVerifyRejectsInvalidTimeoutQCSignature(t *testing.T) { vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epoch: ep} leader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) err := fp.Verify(vs) require.Error(t, err) diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index 46ac55e314..b62497bce4 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -303,12 +303,15 @@ func GenProposalAt(rng utils.Rng, view View) *Proposal { return newProposal(view, utils.GenTimestamp(rng), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng)), GlobalBlockNumber(rng.Uint64())) } -// ProposalAt returns a minimal Proposal at view, consistent with ep. -// No lane ranges and no app proposal — only for tests that care about -// signature weight or epoch binding, not lane/app data. +// ProposalAt returns a minimal non-empty Proposal at view, consistent with ep. +// Includes a single 1-block lane range so Proposal.Verify accepts it (empty +// tipcuts are forbidden). For tests that care about signature weight or epoch +// binding rather than real lane/app data. func ProposalAt(ep *Epoch, view View) *Proposal { view.EpochIndex = ep.EpochIndex() - return newProposal(view, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) + lane := ep.Committee().Lanes().At(0) + header := NewBlock(lane, 0, BlockHeaderHash{}, &Payload{}).Header() + return newProposal(view, time.Time{}, []*LaneRange{NewLaneRange(lane, 0, utils.Some(header))}, utils.None[*AppProposal](), ep.FirstBlock()) } // GenProposalForEpoch generates a Proposal at a specific view whose epochIndex, From 0c965ffaec512d2620ed6545a99e5ce8aef37ccb Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 12:57:53 -0700 Subject: [PATCH 92/98] Remove empty-tipcut AdvanceIfNeeded special case from data.PushQC Empty tipcuts are rejected at proposal verification, so closing roads always have a last global for executeBlock to unlock N+2. Co-authored-by: Cursor --- .../internal/autobahn/data/state.go | 5 --- .../internal/autobahn/data/state_test.go | 35 ------------------- .../internal/autobahn/epoch/registry.go | 7 ++-- .../internal/p2p/giga_router_common.go | 4 +-- 4 files changed, 5 insertions(+), 46 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index a50c53c7a9..4c87ab1676 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -495,11 +495,6 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty inner.epochDuo.Store(*nextDuo) } ctrl.Updated() - // Empty closing tipcut: no globals for runExecute to process, so - // seed N+2 here (non-empty roads unlock on last global in executeBlock). - if applied && gr.First == gr.Next { - s.cfg.Registry.AdvanceIfNeeded(idx) - } } if len(byHash) == 0 { break diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 38bf63d776..b3291866b3 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -1136,38 +1136,3 @@ func TestPushBlockWaitsForQC(t *testing.T) { require.Equal(t, blocks2[0], got) }) } - -func TestPushQC_AdvancesEpochDuoAtBoundary(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - rng := utils.TestRng() - // {0,1}: boundary CommitQC of epoch 0 WaitForDuo's into epoch 1. - registry, keys := epoch.GenRegistryAt(rng, 3, 0) - state := utils.OrPanic1(NewState(&Config{Registry: registry}, - utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) - - ep0 := state.epochDuo.Load().Current - require.Equal(t, types.EpochIndex(0), ep0.EpochIndex()) - _, err := registry.EpochAt(epoch.FirstRoad(2)) - require.Error(t, err, "epoch 2 must be absent before empty closing tipcut") - - // Build a CommitQC at the last road of epoch 0 (no lane blocks needed). - view := types.View{EpochIndex: ep0.EpochIndex(), Index: epoch.LastRoad(0)} - vote := types.NewCommitVote(types.ProposalAt(ep0, view)) - vs := make([]*types.Signed[*types.CommitVote], len(keys)) - for i, k := range keys { - vs[i] = types.Sign(k, vote) - } - qc := types.NewFullCommitQC(types.NewCommitQC(vs), nil) - require.Equal(t, qc.QC().GlobalRange().First, qc.QC().GlobalRange().Next, "empty tipcut") - - require.NoError(t, state.PushQC(t.Context(), qc, nil)) - - if got := state.epochDuo.Load().Current.EpochIndex(); got != 1 { - t.Fatalf("epochDuo.Current after epoch boundary = %d, want 1", got) - } - // Empty closing tipcut must still seed N+2 (no executeBlock to do it). - if _, err := registry.EpochAt(epoch.FirstRoad(2)); err != nil { - t.Fatalf("epoch 2 after empty LastRoad(0) PushQC: %v", err) - } - }) -} diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index c9e7a9bc2c..18a3abc740 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -265,10 +265,9 @@ func (r *Registry) EnsureAfterExecuted(road types.RoadIndex) { } // AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is -// execution-complete. Call after the last global of that road is executed, or -// immediately when the closing tipcut is empty (no globals to run). Earlier -// roads in the epoch are a no-op. Restart uses EnsureAfterExecuted / EnsureDuoAt -// instead of replaying this with synthetic LastRoad tips. +// execution-complete. Call after the last global of that road is executed. +// Earlier roads in the epoch are a no-op. Restart uses EnsureAfterExecuted / +// EnsureDuoAt instead of replaying this with synthetic LastRoad tips. // Committee for N+2 is currently the genesis committee. // TODO: pass the real N+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 4b679fb1f0..3efb3d8930 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -301,8 +301,8 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo } // Seed N+2 when the last global of an epoch's closing road is executed. // A CommitQC can span multiple globals; calling AdvanceIfNeeded on the first - // block of LastRoad(N) would unlock N+2 too early. Empty closing tipcuts - // (no globals) unlock instead from data.PushQC when the QC is applied. + // block of LastRoad(N) would unlock N+2 too early. Empty tipcuts are rejected + // by Proposal.Verify, so every closing road has a last global. // TODO: real N+2 committee once execution derives it. qc, err := r.data.QC(ctx, b.GlobalNumber) if err != nil { From fc3c5b9ccf8f7d0d666141f3bf23868acb3a0ab8 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 15:04:36 -0700 Subject: [PATCH 93/98] Document why runPersist resolves the prune anchor via epochDuo. Live Availability metadata stays in the Prev|Current window; the registry is for restart and admission, not this path. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 7ba787f7cc..4faa45cedd 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -944,6 +944,12 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { batchLanes[lane] = struct{}{} } if anchor, ok := anchorQC.Get(); ok { + // Resolve via epochDuo, not the registry: the prune anchor is live + // Availability metadata and must remain inside the Prev|Current + // operating window (interlock: an epoch leaves that window only + // after its AppQC floor has made it obsolete). The registry is + // independent of Availability pruning (restart + admission / + // execution leash), not the live window. ep, err := s.epochDuo.Load().EpochForRoad(anchor.Proposal().Index()) if err != nil { return fmt.Errorf("EpochForRoad(%d): %w", anchor.Proposal().Index(), err) From ac97bc45de41e154a7f17ff660b80f8edd5468c7 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 15:08:58 -0700 Subject: [PATCH 94/98] Correct newInner comment on WAL lane restore vs advanceEpoch. advanceEpoch does not prune lanes; NewState only WAL-prunes Current. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/inner.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index de07e3ef5a..2016b3ec69 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -151,9 +151,10 @@ func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded uti i.latestCommitQC.Store(utils.Some(i.commitQCs.q[i.commitQCs.next-1])) } - // Restore persisted blocks. Create queues on demand for any lane present - // in the WAL — lanes outside the current epoch will be pruned by - // advanceEpoch in NewState if a boundary was crossed. + // Restore persisted blocks. Create queues on demand for any WAL lane + // (including outside Current). advanceEpoch does not delete old lanes + // (TODO(lane-expiry)); NewState only WAL-prunes Current lanes. Leftover + // queues stay in memory until lane-expiry lands. for lane, bs := range l.blocks { if len(bs) == 0 { continue From 8a11c272cb0696d60a1f087238cdcd3af2aa50f7 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 15:16:30 -0700 Subject: [PATCH 95/98] Align registry tip-interlock docs with interlocking design. Clarify registry vs Avail window, forward/backward leashes, and placeholder seeding vs inventing history. Co-authored-by: Cursor --- .../internal/autobahn/epoch/registry.go | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 18a3abc740..02279c2b6d 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -39,16 +39,23 @@ type registryState struct { // Registry is the authoritative source of epoch and committee information. // All layers (consensus, data, avail) read from it. // +// The registry is independent of Availability pruning: Avail keeps a bounded +// Prev|Current operating window, while the registry may retain older (and +// forward-seeded) epochs for restart and admission / execution-leash logic. +// Live admit/export uses each layer's EpochDuo; Registry.EpochAt / WaitForDuo +// are not a substitute for that window. +// // # Tip interlocking (commit ↔ execution) // // Commit and execution are independent pipelines but must stay interlocked: // -// - Execution cannot pass commit. -// - Epoch unlock is coarse: last road of N-1 registers epoch N+1 -// (AdvanceIfNeeded). Avail does not track per-road exec progress — it only -// gates when sealing epoch N (last CommitQC of N): N+1 must already exist, -// so commit cannot enter N+1 until N-1 has finished. -// - Do not soft-prune, skip roads, or invent epochs to "fix" tip skew. +// - Forward: execution cannot pass commit. +// - Backward (coarse): consensus must not enter epoch N+1 before execution +// has finished epoch N-1. Finishing the last road of epoch N-1 registers +// epoch N+1 (AdvanceIfNeeded: last road of M seeds M+2, so M=N-1 → N+1). +// Avail does not track per-road exec progress — it only gates when sealing +// epoch N (last CommitQC of N): N+1 must already exist. +// - Do not soft-prune, skip roads, or silently repair tip skew. // // Avail keeps a two-epoch operating window {E-1, E}. CommitQCs span a suffix of // E-1 and a prefix of E; AppQC is the prune floor. The window must not drop @@ -59,10 +66,10 @@ type registryState struct { // PushCommitQC and PushAppQC tipcut pushBack (AppQC/CommitQC arrive on separate // streams). Mid-N admits need only the Current window / committee N: // -// - AppQC of N — duo would drop N-1; require N-1 fully pruned first. -// - Registry has epoch N+1 — wait via WaitForDuo (execution unlock before -// Current advances into N+1). Gate on epoch existence, not PushAppHash / -// local-exec cursors. Epoch 0 has no prior epoch to drop / unlock. +// - AppQC of N — duo would drop N-1; require N-1 fully pruned first (prune leash). +// - Registry has epoch N+1 — wait via WaitForDuo (execution leash). Gate on +// epoch existence, not PushAppHash / local-exec cursors. Epoch 0 has no +// prior epoch to drop / unlock. // // Restart: // @@ -71,8 +78,9 @@ type registryState struct { // the giga validator router checks consensus tipcut >= data.CommitTipCut() // (together ⇒ avail >= data). Behind → hard-fail. // - Lead into an unseeded epoch → EpochAt/DuoAt hard-fail (no soft-heal). -// - Live admit/export uses the layer EpochDuo; Registry.EpochAt is for -// load/verify and WaitForDuo, not a substitute for the interlock. +// - SetupInitialDuo may register genesis-committee placeholders for epochs +// implied by committed history (temporary; real committees TBD) — that is +// not inventing roads or repairing inconsistent tips. // - FullCommitQC export: ErrRoadBeforeWindow → data.ErrPruned (caller jumps // ahead). Safe because the boundary AppQC-of-N leash ensures before-window // roads are already pruned; ErrRoadAfterWindow hard-fails (no wait). @@ -264,12 +272,13 @@ func (r *Registry) EnsureAfterExecuted(road types.RoadIndex) { } } -// AdvanceIfNeeded seeds epoch N+2 when the last road of epoch N is -// execution-complete. Call after the last global of that road is executed. -// Earlier roads in the epoch are a no-op. Restart uses EnsureAfterExecuted / -// EnsureDuoAt instead of replaying this with synthetic LastRoad tips. -// Committee for N+2 is currently the genesis committee. -// TODO: pass the real N+2 committee once execution derives it. +// AdvanceIfNeeded seeds epoch M+2 when the last road of epoch M is +// execution-complete (design: finishing N-1 registers N+1, i.e. M=N-1 → M+2=N+1). +// Call after the last global of that road is executed. Earlier roads in the +// epoch are a no-op. Restart uses EnsureAfterExecuted / EnsureDuoAt instead of +// replaying this with synthetic LastRoad tips. +// Committee for M+2 is currently the genesis committee. +// TODO: pass the real M+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { tipEpoch := IndexForRoad(roadIndex) if roadIndex != LastRoad(tipEpoch) { From e17bb8e2ec074ef828e8acb8da6117773d3a3376 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 16:40:56 -0700 Subject: [PATCH 96/98] Document why data PushQC/PushBlock hard-fail before-window. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catch-up stays on epochDuo via the WaitForDuo leash; N-1 after {N,N+1} is stale, and larger gaps belong to snapshot/state sync—not Registry soft-admit. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/state.go | 11 ++++++++++- sei-tendermint/internal/autobahn/epoch/registry.go | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 4c87ab1676..4e68d47a0a 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -433,8 +433,14 @@ func (s *State) CommitTipCut() types.RoadIndex { // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. +// +// Verify via epochDuo only (not Registry.EpochAt). Before-window is a hard error: +// the execution leash (WaitForDuo below) keeps Prev until N-1 is done, so catch-up +// bodies for retained Prev roads still hit EpochForRoad; once the duo is {N,N+1} +// (live seal or SetupInitialDuo), N-1 is stale — do not soft-admit via the registry. +// Gaps larger than the duo window should be resolved using snapshot / state +// sync, not PushQC. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { - // Out-of-window ⇒ below prune tip; reject. ep, err := s.epochDuo.Load().EpochForRoad(qc.QC().Proposal().Index()) if err != nil { return err @@ -533,6 +539,9 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC // PushBlock pushes block to the state. // The QC for n must already be present (guaranteed by PushQC ordering). +// +// Same epochDuo admission as PushQC: pruned heights drop; before-window hard-fails. +// Do not fall back to Registry — see PushQC. func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block *types.Block) error { var ep *types.Epoch for inner, ctrl := range s.inner.Lock() { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 02279c2b6d..5600fb63ee 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -84,6 +84,10 @@ type registryState struct { // - FullCommitQC export: ErrRoadBeforeWindow → data.ErrPruned (caller jumps // ahead). Safe because the boundary AppQC-of-N leash ensures before-window // roads are already pruned; ErrRoadAfterWindow hard-fails (no wait). +// - data.PushQC / PushBlock: before-window hard-fails (epochDuo only). Unlike +// export, ingest must not soft-map to ErrPruned or Registry.EpochAt — the +// WaitForDuo leash keeps Prev available for catch-up fill; a duo already at +// {N,N+1} means N-1 is too old to admit (restart soft-heal forbidden). type Registry struct { state utils.RWMutex[*registryState] // highestEpoch is a monotonic high-water mark for WaitForDuo. From ef306aaa1b40b4cccf3f432fb4f2b6370c6701da Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 16:42:56 -0700 Subject: [PATCH 97/98] Guard WaitForLocalCapacity against missing local lane. Return ErrBadLane instead of nil-dereferencing after the laneState map refactor, matching ProduceLocalBlock and PushBlock. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 4faa45cedd..e850b9679a 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -762,8 +762,12 @@ func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.Ful func (s *State) WaitForLocalCapacity(ctx context.Context, toProduce types.BlockNumber) error { lane := s.key.Public() for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes[lane] + if !ok { + return ErrBadLane + } if err := ctrl.WaitUntil(ctx, func() bool { - return toProduce < inner.lanes[lane].persistedBlockStart+BlocksPerLane + return toProduce < ls.persistedBlockStart+BlocksPerLane }); err != nil { return err } From 6bc4551a57401d7a2c8b246355a9e8a6deb5ac35 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 20 Jul 2026 16:55:42 -0700 Subject: [PATCH 98/98] Snapshot epochDuo once in avail PushVote/PushBlock. Avoid verify vs count using different Current across a boundary advanceEpoch; note restart CommitQC seeding is temporary until epoch info is on-chain. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 10 +++++++--- sei-tendermint/internal/autobahn/data/state.go | 6 ++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index e850b9679a..f7552f503c 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -607,7 +607,9 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos if p.Key() != h.Lane() { return fmt.Errorf("signer %v does not match lane %v", p.Key(), h.Lane()) } - c := s.epochDuo.Load().Current.Committee() + // Snapshot once so verify cannot race a boundary advanceEpoch (same as PushVote). + duo := s.epochDuo.Load() + c := duo.Current.Committee() if err := p.Msg().Verify(c); err != nil { return fmt.Errorf("block.Verify(): %w", err) } @@ -659,7 +661,10 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos // Waits until the lane has enough capacity for the new vote. // It does NOT wait for the previous votes. func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { - c := s.epochDuo.Load().Current.Committee() + // One duo snapshot for verify and count: a boundary advanceEpoch between + // those steps must not leave them on different Current committees. + duo := s.epochDuo.Load() + c := duo.Current.Committee() if err := vote.Msg().Verify(c); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } @@ -684,7 +689,6 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - duo := s.epochDuo.Load() if q.q[h.BlockNumber()].pushVote(duo.Current, vote).IsPresent() { ctrl.Updated() } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 4e68d47a0a..59fc567866 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -329,6 +329,12 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { cfg.Registry.SetupInitialDuo(lastExecuted, commitQCs) // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. + // + // TODO(autobahn): each QC needs Registry.EpochAt because SetupInitialDuo + // pre-seeded placeholders for the whole WAL CommitQC span. Once epoch / + // committee info lives on the block, proposal, or QC, avail/consensus + // persisted tips can restore independently and this per-CommitQC seeding + // / lookup on restart goes away. for _, qc := range loadedQCs { ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) if err != nil {