From e9ec93664aad3143029193ba4b35fd51a0ff9901 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 21 Jul 2026 10:29:31 -0700 Subject: [PATCH 01/85] feat(autobahn): multi-epoch Prev|Current window for CommitQC/AppQC (CON-358) Keep a sliding EpochDuo so late AppQC from the previous epoch can finish while tipcuts advance in Current. Data owns restart seeding; avail/data/ consensus each advance their window on boundary CommitQCs with prune and execution leashes; hard-fail tip skew and before-window ingest. Co-authored-by: Cursor --- .../block/blocksim/block_generator.go | 4 +- sei-tendermint/AGENTS.md | 1 + .../autobahn/types/committee_test.go | 4 +- sei-tendermint/autobahn/types/epoch.go | 13 +- sei-tendermint/autobahn/types/epoch_duo.go | 94 +++ .../autobahn/types/epoch_duo_test.go | 138 ++++ sei-tendermint/autobahn/types/proposal.go | 44 +- .../autobahn/types/proposal_test.go | 232 +++--- sei-tendermint/autobahn/types/testonly.go | 6 +- .../autobahn/types/wireguard_test.go | 2 +- .../internal/autobahn/avail/block_votes.go | 90 ++- .../autobahn/avail/block_votes_test.go | 128 +++ .../internal/autobahn/avail/conv_test.go | 2 +- .../internal/autobahn/avail/inner.go | 211 +++-- .../internal/autobahn/avail/inner_test.go | 293 +++++-- .../internal/autobahn/avail/state.go | 455 ++++++++--- .../internal/autobahn/avail/state_test.go | 730 +++++++++++++++++- .../internal/autobahn/avail/subscriptions.go | 8 +- .../internal/autobahn/avail/testonly.go | 2 +- .../internal/autobahn/consensus/inner.go | 77 +- .../internal/autobahn/consensus/inner_test.go | 205 ++--- .../consensus/persist/commitqcs_test.go | 26 +- .../autobahn/consensus/persisted_inner.go | 21 +- .../internal/autobahn/consensus/state.go | 67 +- .../internal/autobahn/consensus/state_test.go | 42 +- .../internal/autobahn/data/state.go | 198 ++++- .../autobahn/data/state_recovery_test.go | 22 +- .../internal/autobahn/data/state_test.go | 28 +- .../internal/autobahn/data/testonly.go | 2 +- .../internal/autobahn/epoch/registry.go | 305 +++++++- .../internal/autobahn/epoch/registry_test.go | 245 +++++- .../internal/autobahn/epoch/testonly.go | 50 +- .../autobahn/producer/mempool_test.go | 2 +- sei-tendermint/internal/p2p/giga/avail.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 | 35 +- .../internal/p2p/giga_router_fullnode.go | 12 +- .../internal/p2p/giga_router_validator.go | 39 +- .../p2p/giga_router_validator_test.go | 15 + 41 files changed, 3133 insertions(+), 723 deletions(-) create mode 100644 sei-tendermint/autobahn/types/epoch_duo.go create mode 100644 sei-tendermint/autobahn/types/epoch_duo_test.go create mode 100644 sei-tendermint/internal/autobahn/avail/block_votes_test.go 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]() diff --git a/sei-tendermint/AGENTS.md b/sei-tendermint/AGENTS.md index ee38c7c81e..e844aa55a1 100644 --- a/sei-tendermint/AGENTS.md +++ b/sei-tendermint/AGENTS.md @@ -33,6 +33,7 @@ Within sei-tendermint subdirectory optional uint64 index = 1; // required optional AppProposal app = 4; // optional * 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/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_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go new file mode 100644 index 0000000000..54ea9badbf --- /dev/null +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -0,0 +1,94 @@ +package types + +import ( + "errors" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// 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 +} + +// ErrRoadBeforeWindow is returned by EpochForRoad when the road is older than +// WindowFirst (behind the duo). +var ErrRoadBeforeWindow = errors.New("road before epoch duo window") + +// 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} +} + +// 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 + } + } + 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). +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 { + 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 [" + sep := "" + for _, oep := range w.all() { + if ep, ok := oep.Get(); ok { + s += fmt.Sprintf("%s%d", sep, ep.EpochIndex()) + sep = ", " + } + } + return s + "]" +} diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go new file mode 100644 index 0000000000..2b91986489 --- /dev/null +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -0,0 +1,138 @@ +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" +) + +func testDuoEpochs(t *testing.T) (prev, current *types.Epoch) { + t.Helper() + rng := utils.TestRng() + weights := map[types.PublicKey]uint64{} + 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) + return prev, current +} + +func TestEpochForRoad_HitsCurrentEpoch(t *testing.T) { + _, current := testDuoEpochs(t) + w := types.EpochDuo{Current: current} + ep, err := w.EpochForRoad(150) + if err != nil { + t.Fatalf("EpochForRoad(150): %v", err) + } + if ep != current { + t.Fatalf("got %v, want current", ep) + } +} + +func TestEpochForRoad_HitsPrevEpoch(t *testing.T) { + 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 != prev { + t.Fatalf("got %v, want prev", ep) + } +} + +func TestEpochForRoad_OutsideWindowReturnsError(t *testing.T) { + _, current := testDuoEpochs(t) + w := types.EpochDuo{Current: current} + _, 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) + } +} + +func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { + rng := utils.TestRng() + 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) + w := types.EpochDuo{Prev: utils.Some(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_AbsentPrevSkipped(t *testing.T) { + _, current := testDuoEpochs(t) + w := types.EpochDuo{Current: current} + _, err := w.EpochForRoad(50) + if !errors.Is(err, types.ErrRoadBeforeWindow) { + t.Fatalf("EpochForRoad(50) with absent Prev = %v, want ErrRoadBeforeWindow", err) + } +} + +func TestEpochForRoad_BeforeAndAfterWithPrev(t *testing.T) { + prev, current := testDuoEpochs(t) + w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + if _, err := w.EpochForRoad(50); err != nil { + t.Fatalf("EpochForRoad(50) in prev: %v", err) + } + if _, err := w.EpochForRoad(150); err != nil { + t.Fatalf("EpochForRoad(150) in current: %v", err) + } + _, err := w.EpochForRoad(200) + if !errors.Is(err, types.ErrRoadAfterWindow) { + t.Fatalf("EpochForRoad(200) = %v, want ErrRoadAfterWindow", err) + } +} + +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/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index bb78f668c1..00c061d3ee 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 } @@ -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. @@ -299,7 +303,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()) } @@ -411,8 +415,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 { @@ -440,7 +446,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 @@ -459,7 +465,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. @@ -498,9 +504,15 @@ 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) + } + 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 { @@ -510,7 +522,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 0228ccc1c3..4f8e8f7304 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -75,7 +75,7 @@ func TestProposalVerifyRejectsEmptyTipcut(t *testing.T) { rng := utils.TestRng() committee, _ := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} // Direct Proposal.Verify rejects empty tipcuts (no LaneQCs / zero GlobalRange). // Local propose waits via WaitForLaneQCs; verification must refuse empty tipcuts @@ -97,7 +97,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. @@ -113,7 +113,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() @@ -133,7 +133,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() @@ -156,7 +156,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( @@ -181,13 +181,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, oneLaneQCMap(rng, committee, keys, vs), 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)) }) @@ -195,7 +195,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)) @@ -213,8 +213,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( @@ -235,13 +235,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(), oneLaneQCMap(rng, committee, keys, vs0), 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) } @@ -250,7 +250,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. @@ -267,7 +267,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(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -294,7 +294,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(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -320,7 +320,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(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -352,7 +352,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(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -384,7 +384,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(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -416,7 +416,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(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -454,7 +454,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() @@ -476,7 +476,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() @@ -500,7 +500,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() @@ -561,7 +561,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(), @@ -591,10 +591,10 @@ 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()) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epoch: ep} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: EpochDuo{Current: 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} + vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epochs: EpochDuo{Current: 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]()) @@ -607,7 +607,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(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -625,51 +625,73 @@ 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()) - - // 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(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) - - tamperedFP := &FullProposal{ - proposal: fp.proposal, - laneQCs: fp.laneQCs, + 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()) + + // Fresh AppQC: globalNumber 0 < FirstBlock 1. + goodAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) + + tamperedFP := &FullProposal{ + proposal: fp.proposal, + laneQCs: fp.laneQCs, + } + 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(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) - - differentAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) - tamperedFP := &FullProposal{ - proposal: fp.proposal, - laneQCs: fp.laneQCs, - 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(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) + + differentAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + tamperedFP := &FullProposal{ + proposal: fp.proposal, + laneQCs: fp.laneQCs, + 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 { @@ -682,46 +704,60 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { return NewAppQC(votes) } - // app epoch matches proposal epoch — accepted. - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(0)))) + // AppQC from Current — accepted. + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), 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(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(1)))) + // AppQC from Prev (N-1) — accepted. + fpPrev := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(0)))) + require.NoError(t, fpPrev.Verify(vs)) + + // AppQC outside the duo — rejected. + fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), 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(), oneLaneQCMap(rng, committee, keys, vs), 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, - laneQCs: fp.laneQCs, - 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(), oneLaneQCMap(rng, committee, keys, vs), 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, + laneQCs: fp.laneQCs, + 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() @@ -750,7 +786,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)) @@ -771,7 +807,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()) @@ -788,7 +824,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(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -804,7 +840,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. @@ -827,7 +863,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(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -843,7 +879,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. @@ -879,7 +915,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(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -894,17 +930,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 ffc3d5a73d..d31972cd11 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -23,7 +23,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}} if len(laneQCs) == 0 { laneQCs = oneBlockLaneQCMap(vs, keys) } @@ -45,7 +45,7 @@ func BuildCommitQC( // oneBlockLaneQCMap builds a single LaneQC advancing the first committee lane by one block. func oneBlockLaneQCMap(vs ViewSpec, keys []SecretKey) map[LaneID]*LaneQC { - c := vs.Epoch.Committee() + c := vs.Epoch().Committee() lane := c.Lanes().At(0) n := LaneRangeOpt(vs.CommitQC, lane).Next() header := NewBlock(lane, n, BlockHeaderHash{}, &Payload{}).Header() @@ -295,7 +295,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/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/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index ba84945e1e..2b76bb15a2 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -2,42 +2,96 @@ package avail import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// 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 +} + +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) + if s.weight < quorum { + return utils.None[*types.LaneQC]() + } + s.qc = types.NewLaneQC(s.votes) + return utils.Some(s.qc) +} + +// blockVotes weights Current only; reweight on epoch advance. 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{}, } } -// 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() +// 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() - h := vote.Msg().Header().Hash() if _, ok := bv.byKey[k]; ok { - return nil, false + return utils.None[*types.LaneQC]() } bv.byKey[k] = vote - byHash, ok := bv.byHash[h] + + h := vote.Msg().Header().Hash() + set, ok := bv.byHash[h] if !ok { - byHash = &voteSet[*types.Signed[*types.LaneVote]]{} - bv.byHash[h] = byHash + set = &laneVoteSet{header: vote.Msg().Header()} + bv.byHash[h] = set + } + w := ep.Committee().Weight(k) + if w == 0 { + return utils.None[*types.LaneQC]() + } + return set.add(w, ep.Committee().LaneQuorum(), 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() } - if byHash.weight >= c.LaneQuorum() { - return nil, false + 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 + } } - byHash.weight += c.Weight(k) - byHash.votes = append(byHash.votes, vote) - if byHash.weight >= c.LaneQuorum() { - return types.NewLaneQC(byHash.votes), true + return quorumReached +} + +func (bv blockVotes) laneQC() utils.Option[*types.LaneQC] { + for _, set := range bv.byHash { + if set.qc != nil { + return utils.Some(set.qc) + } } - return nil, false + 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 new file mode 100644 index 0000000000..e75175495e --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -0,0 +1,128 @@ +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" +) + +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, Next: first + 108_000} + return types.NewEpoch(idx, rr, time.Time{}, c, 0) +} + +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{} + require.False(t, set.add(1, 2, mkVote()).IsPresent()) + require.Equal(t, uint64(1), set.weight) + require.Len(t, set.votes, 1) + 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) + + require.False(t, set.add(1, 2, mkVote()).IsPresent()) + require.Equal(t, uint64(2), set.weight) + require.Len(t, set.votes, 2) + + heavy := &laneVoteSet{} + 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) +} + +func TestPushVote_ZeroWeightKeptForReweight(t *testing.T) { + rng := utils.TestRng() + keyA := types.GenSecretKey(rng) + keyZ := types.GenSecretKey(rng) + + ep0 := makeVoteEpoch(0, 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(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_CurrentOnly(t *testing.T) { + rng := utils.TestRng() + 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}) + + lane := keyA.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + h := header.Hash() + + bv := newBlockVotes() + 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) + + qc, ok := bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).Get() + require.True(t, ok) + require.Equal(t, qc, bv.byHash[h].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) { + 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, + } + ep := makeVoteEpoch(0, weights) + + lane := keyA.Public() + 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(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(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/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..2016b3ec69 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -7,42 +7,47 @@ 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" ) -// 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 { - epoch *types.Epoch latestAppQC utils.Option[*types.AppQC] latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] + epochDuo utils.AtomicSend[types.EpochDuo] // 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. @@ -57,51 +62,79 @@ type loadedAvailState struct { blocks map[types.LaneID][]persist.LoadedBlock } -func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inner, error) { - 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() { - votes[lane] = newQueue[types.BlockNumber, blockVotes]() - blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() +// 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) + 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(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() } i := &inner{ - epoch: epoch, - latestAppQC: utils.None[*types.AppQC](), - latestCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), - 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)), - } - i.appVotes.prune(epoch.FirstBlock()) - + latestAppQC: utils.None[*types.AppQC](), + latestCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + epochDuo: utils.NewAtomicSend(startEpochDuo), + appVotes: newQueue[types.GlobalBlockNumber, appVotes](), + commitQCs: newQueue[types.RoadIndex, *types.CommitQC](), + lanes: lanes, + } l, ok := loaded.Get() if !ok { + if startEpochDuo.Current.EpochIndex() > 0 { + return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) + } + i.appVotes.prune(startEpochDuo.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(). + // 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", 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 := 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) } - for lane := range i.blocks { - i.persistedBlockStart[lane] = anchor.CommitQC.LaneRange(lane).First() + // 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() } + } 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(startEpochDuo.Current.FirstBlock()) + } else { + 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. 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 @@ -109,20 +142,25 @@ func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inne 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 { 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 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 { - q, ok := i.blocks[lane] - if !ok || len(bs) == 0 { + if len(bs) == 0 { continue } + ls := i.getOrInsertLane(lane) + q := ls.blocks var lastHash types.BlockHeaderHash for j, b := range bs { if q.Len() >= BlocksPerLane { @@ -140,27 +178,52 @@ func newInner(epoch *types.Epoch, loaded utils.Option[*loadedAvailState]) (*inne q.pushBack(b.Proposal) } if q.next > q.first { - i.nextBlockToPersist[lane] = q.next + ls.nextBlockToPersist = q.next } } 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() - for _, byHash := range i.votes[lane].q[n].byHash { - if byHash.weight >= c.LaneQuorum() { - return types.NewLaneQC(byHash.votes[:]), true +// 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() +} + +// 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(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(current) } } - return nil, false + 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(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()) @@ -171,17 +234,13 @@ func (i *inner) prune(c *types.Committee, appQC *types.AppQC, commitQC *types.Co 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 := 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 a6deb0a076..e0b5e1e874 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -19,7 +19,9 @@ func newTestDataState(cfg *data.Config) *data.State { 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) makeCommitQC := func(prev utils.Option[*types.CommitQC]) *types.CommitQC { l := keys[0].Public() @@ -28,7 +30,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() @@ -44,25 +46,24 @@ func TestPruneMismatchedIndices(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.Some(t.TempDir())) 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 = newTestDataState(&data.Config{Registry: registry}) state, err = NewState(keys[0], ds, utils.Some(t.TempDir())) 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)) @@ -70,28 +71,28 @@ 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(registry.LatestEpoch(), 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()) - 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) } } 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)) @@ -105,11 +106,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(registry.LatestEpoch(), 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. @@ -118,9 +119,22 @@ func TestNewInnerLoadedNoAnchor(t *testing.T) { require.Equal(t, registry.FirstBlock(), i.appVotes.first) } +func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { + rng := utils.TestRng() + 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(registry, duo, utils.Some(&loadedAvailState{})) + require.Error(t, err) + _, 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. @@ -136,10 +150,10 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(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 { @@ -147,35 +161,35 @@ 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) } } } 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(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(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) } 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() @@ -185,11 +199,11 @@ 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(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) 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) } @@ -198,7 +212,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() @@ -222,25 +236,25 @@ 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(registry, utils.OrPanic1(registry.DuoAt(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) { 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) @@ -259,7 +273,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), 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. @@ -277,7 +291,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) @@ -305,7 +319,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), 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. @@ -329,7 +343,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. @@ -366,7 +380,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(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // AppQC restored. @@ -379,10 +393,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() @@ -392,10 +406,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(registry.LatestEpoch(), 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. @@ -403,10 +417,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. @@ -414,7 +428,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()), @@ -435,23 +449,23 @@ 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) // 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") } 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, @@ -470,10 +484,10 @@ 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(registry, 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])) @@ -481,7 +495,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. @@ -499,10 +513,10 @@ 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(registry, 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])) @@ -515,13 +529,13 @@ 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) } } 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]() @@ -542,20 +556,20 @@ func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry.LatestEpoch(), 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(registry.LatestEpoch(), 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) @@ -566,7 +580,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 @@ -590,7 +604,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) require.NoError(t, err) // Only QC@10 loaded. @@ -610,7 +624,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]. @@ -639,10 +653,10 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, 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() @@ -652,10 +666,10 @@ 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(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]() @@ -678,14 +692,14 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry.LatestEpoch(), 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 @@ -702,14 +716,14 @@ func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), 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. @@ -730,14 +744,14 @@ func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), 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. @@ -756,14 +770,14 @@ func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), 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. @@ -799,20 +813,20 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry.LatestEpoch(), 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. 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) } } 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. @@ -835,7 +849,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { }, } - i, err := newInner(registry.LatestEpoch(), 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. @@ -843,3 +857,114 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { // CommitQCs 1 and 2 should still be loaded. 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.GenRegistryAt(rng, 4, 0) + 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 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.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) + 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(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") + require.NotEqual(t, tipFirst, inner.appVotes.first) +} + +func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { + rng := utils.TestRng() + registry, _ := epoch.GenRegistryAt(rng, 4, 0) + duo := utils.OrPanic1(registry.DuoAt(0)) + + i, err := newInner(registry, duo, utils.None[*loadedAvailState]()) + require.NoError(t, err) + + // All current lanes are present after construction. + 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 duo — until lane-expiry lands, advance must not + // delete it (ever-growing map). + var realLane types.LaneID + for l := range duo.Current.Committee().Lanes().All() { + realLane = l + break + } + bogusSK := types.GenSecretKey(rng) + bogusLane := bogusSK.Public() + i.lanes[bogusLane] = newLaneState() + + 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") +} + +func TestAdvanceEpoch_EmptyQueuesNoop(t *testing.T) { + rng := utils.TestRng() + registry, _ := epoch.GenRegistryAt(rng, 4, 0) + duo := utils.OrPanic1(registry.DuoAt(0)) + + 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 + // keeps the current lane set intact. + i.advanceEpoch(duo) + for lane := range duo.Current.Committee().Lanes().All() { + require.Contains(t, i.lanes, lane) + } +} + +func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { + rng := utils.TestRng() + registry, _ := epoch.GenRegistryAt(rng, 4, 0) + + // duo0: Prev=nil, Current=epoch0 + duo0 := utils.OrPanic1(registry.DuoAt(0)) + i, err := newInner(registry, 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 duo0.Current.Committee().Lanes().All() { + epoch0Lane = l + break + } + require.Contains(t, i.lanes, epoch0Lane, "epoch0 lane missing before reweight") + + // duo1: Prev=epoch0, Current=epoch1 + duo1 := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(1))) + 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 40adf5834a..92732319de 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" @@ -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] + 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. @@ -156,17 +158,29 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } - ep := data.Registry().LatestEpoch() - inner, err := newInner(ep, loaded) + // Operating duo is the CommitQC tipcut (not the prune-anchor road). + // 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 { + commitTip = ls.nextCommitQC() + } + startDuo, err := data.Registry().DuoAt(commitTip) + if err != nil { + return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) + } + inner, err := newInner(data.Registry(), startDuo, loaded) if err != nil { return nil, err } // Truncate WAL entries below the prune anchor that were filtered out by - // loadPersistedState. + // loadPersistedState. Lanes come from the operating (tip) duo. if ls, ok := loaded.Get(); ok { if anchor, ok := ls.pruneAnchor.Get(); ok { - for lane := range ep.Committee().Lanes().All() { + 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) } @@ -181,6 +195,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin key: key, data: data, inner: utils.NewWatch(inner), + epochDuo: inner.epochDuo.Subscribe(), persisters: pers, }, nil } @@ -192,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 @@ -212,6 +235,127 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error return err } +// 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 lookup(duo).IsPresent() { + return true + } + return roadIdx < windowFirst(duo) + }) + if err != nil { + return utils.None[*types.Epoch](), err + } + 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 +// 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) { + 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 nil, err + } + 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 ep, nil +} + +// waitForAppQCEpoch blocks until latest AppQC is from epochIdx or later. +// 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 + } + 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") +} + +// waitCommitEpochLeashes enforces tip-interlock gates before inserting a +// 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. +func (s *State) waitCommitEpochLeashes( + ctx context.Context, + epochIdx types.EpochIndex, + closingEpoch bool, + incoming utils.Option[*types.AppQC], +) error { + if epochIdx == 0 || !closingEpoch { + return nil + } + // 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: execution finished N-1 (usually 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() { @@ -254,7 +398,9 @@ 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, for Current to cover the road (too early blocks; +// 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 { @@ -262,25 +408,44 @@ 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()) + // 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. + ep, err := s.admitRoadOrDrop(ctx, idx, "CommitQC", s.waitCurrentForRoad) + if err != nil || ep == nil { + return err } 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(). + // Wait — do not drop (caller may not retry). + closing := idx+1 == ep.RoadRange().Next + if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex(), closing, utils.None[*types.AppQC]()); err != nil { + return err + } + + // Boundary: switch to the next epoch on Current's last CommitQC. + // Resolve next duo off-lock (WaitForDuo). + 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() { 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()) + if nextDuo != nil { + inner.advanceEpoch(*nextDuo) } 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 } @@ -289,19 +454,20 @@ 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() + // A vote may arrive before its CommitQC advances the tip. + if err := s.waitForCommitQC(ctx, idx); err != nil { + return err + } + // Too-early roads (ahead of Prev|Current) backpressure; too-late are dropped. + ep, err := s.admitRoadOrDrop(ctx, idx, "AppVote", s.waitEpochForRoad) + if err != nil || ep == nil { + return err } committee := ep.Committee() - idx := v.Msg().Proposal().RoadIndex() 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) { @@ -322,7 +488,7 @@ 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 } @@ -334,43 +500,71 @@ 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 { +// 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() { if types.NextOpt(inner.latestAppQC) > appQC.Proposal().RoadIndex() { return nil } } - ep, ok := s.data.Registry().EpochByIndex(commitQC.Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", commitQC.Proposal().EpochIndex()) - } - if err := appQC.Verify(ep.Committee()); err != nil { - return fmt.Errorf("appQC.Verify(): %w", err) - } - if err := commitQC.Verify(ep); err != nil { - return fmt.Errorf("commitQC.Verify(): %w", err) - } + // 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) } - // 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") } + idx := commitQC.Proposal().Index() + ep, err := s.admitRoadOrDrop(ctx, idx, "AppQC", s.waitEpochForRoad) + if err != nil || ep == nil { + return err + } + if err := appQC.Verify(ep.Committee()); err != nil { + return fmt.Errorf("appQC.Verify(): %w", err) + } + 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. + // 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. + 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(ep.Committee(), appQC, commitQC) + 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 } @@ -378,8 +572,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 @@ -390,10 +584,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 } @@ -412,21 +607,23 @@ 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 := p.Msg().Verify(c); err != nil { - return err - } - return p.VerifySig(c) - }); err != nil { + // 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) + } + if err := p.VerifySig(c); err != nil { 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 } @@ -464,22 +661,25 @@ 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 := vote.Msg().Verify(c); err != nil { - return err - } - return vote.VerifySig(c) - }); err != nil { + // 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) + } + if err := vote.VerifySig(c); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } 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 } @@ -489,7 +689,7 @@ 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 { + if q.q[h.BlockNumber()].pushVote(duo.Current, vote).IsPresent() { ctrl.Updated() } } @@ -505,7 +705,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.votes[lr.Lane()] + ls, ok := inner.lanes[lr.Lane()] + if !ok { + return nil, types.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 { @@ -513,11 +717,9 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc if q.first > lr.First() { return nil, types.ErrPruned } - // Check if we have the header. - if entry, ok := q.q[n].byHash[want]; ok { - h := entry.votes[0].Msg().Header() - want = h.ParentHash() - headers[len(headers)-i-1] = h + 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. @@ -530,34 +732,46 @@ 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) { - // Collect the CommitQC. +// fullCommitQC returns the FullCommitQC for road n and the signing epoch. +// Returns types.ErrPruned when the CommitQC/headers were pruned, or when the +// road is behind the epoch duo (ErrRoadBeforeWindow) so the export loop can +// 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 { - return nil, err + return nil, nil, err } - // 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.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) + if err != nil { + if errors.Is(err, types.ErrRoadBeforeWindow) { + return nil, nil, types.ErrPruned + } + return nil, nil, err } + var commitHeaders []*types.BlockHeader 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. 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.persistedBlockStart[lane]+BlocksPerLane + return toProduce < ls.persistedBlockStart+BlocksPerLane }); err != nil { return err } @@ -565,18 +779,20 @@ 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.epochDuo.Load().Current 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).Get(); ok { laneQCs[lane] = qc } else { break @@ -584,10 +800,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 } } } @@ -605,11 +821,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 { @@ -641,7 +858,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, _, err := s.fullCommitQC(ctx, n) if err != nil { if errors.Is(err, types.ErrPruned) { continue @@ -649,23 +866,17 @@ func (s *State) Run(ctx context.Context) error { return err } - // 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()) - } - 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() { - 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 { - // 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()) - } + for _, h := range qc.Headers() { + ls, ok := inner.lanes[h.Lane()] + if !ok { + continue + } + if b, ok := ls.blocks.q[h.BlockNumber()]; ok { + // No need to check against headers; PushQC filters mismatches. + blocks = append(blocks, b.Msg().Block()) } } } @@ -741,9 +952,15 @@ 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()) + // 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) } for lane := range ep.Committee().Lanes().All() { batchLanes[lane] = struct{}{} @@ -774,10 +991,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() @@ -790,7 +1007,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() { - inner.nextBlockToPersist[lane] = next + ls, ok := inner.lanes[lane] + if !ok { + return + } + ls.nextBlockToPersist = next ctrl.Updated() } } @@ -819,8 +1040,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 } } @@ -828,10 +1049,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/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 90d0506df5..9aa0a63983 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -25,6 +25,19 @@ var ( noCommitQCCB = utils.None[func(*types.CommitQC)]() ) +// 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() + 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) + } +} + type byLane[T any] map[types.LaneID][]T func makeAppVotes(keys []types.SecretKey, proposal *types.AppProposal) []*types.Signed[*types.AppVote] { @@ -124,7 +137,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 { @@ -175,7 +188,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) } @@ -212,7 +225,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) } @@ -249,7 +262,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() @@ -296,7 +309,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) } @@ -354,7 +367,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() @@ -364,7 +377,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, @@ -403,15 +416,55 @@ 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) }) } +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 := newTestDataState(&data.Config{Registry: registry}) + 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, utils.None[*types.AppQC]()), 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, utils.None[*types.AppQC]()) }() + require.NoError(t, state.PushAppQC(ctx, appQC, qc0)) + require.NoError(t, <-done) + 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, utils.None[*types.AppQC]()), context.DeadlineExceeded) +} + func TestPushBlockRejectsBadParentHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) @@ -434,7 +487,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 := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) @@ -450,7 +503,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 := registry.FirstBlock() t.Run("empty dir loads fresh state", func(t *testing.T) { @@ -791,3 +845,657 @@ func TestNewStateWithPersistence(t *testing.T) { require.Error(t, err) }) } + +func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + + committeeKey := types.GenSecretKey(rng) + committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + committeeKey.Public(): 1, + })) + registry := utils.OrPanic1(epoch.NewRegistry(committee, 0, time.Time{})) + ep := registry.LatestEpoch() + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(committeeKey, ds, utils.None[string]()) + require.NoError(t, err) + + require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + s.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) + s.SpawnBgNamed("avail.Run", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) + + // Produce and vote on a block for the committee lane. + b, err := state.produceLocalBlock(0, committeeKey, types.GenPayload(rng)) + if err != nil { + return err + } + for _, vote := range makeLaneVotes([]types.SecretKey{committeeKey}, b.Msg().Block().Header()) { + if err := state.PushVote(ctx, vote); err != nil { + return err + } + } + + 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) + } + } + if _, ok := laneQCs[committeeKey.Public()]; !ok { + return fmt.Errorf("WaitForLaneQCs missing committee lane") + } + return nil + })) +} + +func TestPushAppQCOutsideWindowDrops(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := newTestDataState(&data.Config{Registry: registry}) + 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()))) + + // 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()) +} + +func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; tip starts at road 0 + ds := newTestDataState(&data.Config{Registry: registry}) + 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.FirstRoad(1))) + proposal := types.NewAppProposal( + 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() + + 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 := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = state.waitEpochForRoad(ctx, epoch.FirstRoad(2)) + require.ErrorIs(t, err, context.Canceled) +} + +func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 2) + + ep, err := state.waitEpochForRoad(t.Context(), 0) + require.NoError(t, err) + require.False(t, ep.IsPresent()) +} + +func TestWaitCurrentForRoadFutureBlocks(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + 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 := newTestDataState(&data.Config{Registry: registry}) + 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 := newTestDataState(&data.Config{Registry: registry}) + 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 := newTestDataState(&data.Config{Registry: registry}) + 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") +} + +// 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) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := newTestDataState(&data.Config{Registry: registry}) + 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 behind WindowFirst. + registerDuoAtEpoch(state, 2) + + _, _, err = state.fullCommitQC(t.Context(), 0) + require.ErrorIs(t, err, types.ErrPruned) +} + +// 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 := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // 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, types.ErrPruned) +} + +// 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)) + 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 := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + + lane := keys[0].Public() + 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.commitQCs.first = epoch.FirstRoad(1) + inner.commitQCs.next = epoch.FirstRoad(1) + } + state.markCommitQCsPersisted(prevTip) + + require.NoError(t, state.PushCommitQC(t.Context(), qc1)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.FirstRoad(1)+1, inner.commitQCs.next) + } +} + +// 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)) + 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 := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + + lane := keys[0].Public() + // 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), + }))), + })), map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockMid.Header()))}, + utils.None[*types.AppQC]()) + appQC1 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + 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.LastRoad(1) + } + 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 := newTestDataState(&data.Config{Registry: registry}) + 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() + // 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(), 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()) + } +} + +// 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 := newTestDataState(&data.Config{Registry: registry}) + 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 := newTestDataState(&data.Config{Registry: registry}) + 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)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + + ds := newTestDataState(&data.Config{Registry: registry}) + 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). +func TestPushAppQCPreviousEpoch(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 3, 0) // {0,1} + epochN1 := utils.OrPanic1(registry.EpochAt(0)) // epoch 0 (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]()) + gr := commitQC.GlobalRange() + appProposal := types.NewAppProposal(gr.First, commitQC.Index(), types.GenAppHash(rng), epochN1.EpochIndex()) + appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // 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), + "late AppQC from epoch N-1 should be accepted after Current advanced to N") + require.True(t, state.LastAppQC().IsPresent()) +} + +// TestRestartDuoFromCommitTipNeedsSetup covers restart seeding: DuoAt at the +// 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 := epoch.FirstRoad(2) + _, err := registry.DuoAt(tip2) + require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") + + // CommitQC closing epoch 1 opens {1} then tipcut/execution add 2 and 3. + tip1 := epoch.LastRoad(1) + registry.SetupInitialDuo( + utils.Some(tip1), + utils.Some(epoch.CommitQCSpan{First: tip1, Last: tip1}), + ) + tipDuo2, err := registry.DuoAt(tip2) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(2), tipDuo2.Current.EpochIndex()) + + _, err = newInner(registry, tipDuo2, utils.None[*loadedAvailState]()) + require.Error(t, err, "epoch > 0 requires a prune anchor") +} + +func TestNextCommitQC(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 4) + require.Equal(t, types.RoadIndex(0), (&loadedAvailState{}).nextCommitQC()) + + 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), (&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), (&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), (&loadedAvailState{ + pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC9, CommitQC: qcs[9]}), + }).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]}, + }, + }).nextCommitQC(), "stale WAL below anchor must not win over anchor tipcut") +} diff --git a/sei-tendermint/internal/autobahn/avail/subscriptions.go b/sei-tendermint/internal/autobahn/avail/subscriptions.go index 76c6634409..7a98861118 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions.go @@ -46,10 +46,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 } diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index aee8eead1d..2f190d5248 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -77,7 +77,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/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index e551613386..01410eccb2 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -92,20 +92,23 @@ 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 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() } // 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 @@ -117,25 +120,50 @@ 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 { + // View epoch = tipcut road; CommitQC may still be the prior epoch's last road. + // 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 { + return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) + } + 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, epoch: ep}, nil + duo := types.EpochDuo{Current: viewEpoch} + if viewEpoch.EpochIndex() > 0 { + prev, err := registry.EpochAt(epoch.FirstRoad(viewEpoch.EpochIndex() - 1)) + 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 { - i := s.innerRecv.Load() - if qc.Proposal().Index() < i.View().Index { + if qc.Proposal().Index() < s.innerRecv.Load().View().Index { return nil } - if err := qc.Verify(i.epoch); err != 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() { @@ -143,9 +171,20 @@ 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}) + nextRoad := qc.Proposal().Index() + 1 + nextDuo := i.epochs + if !i.epochs.Current.RoadRange().Has(nextRoad) { + // Boundary or coalesced tip past Current: open duo at tipcut. + // Invariant: seeded before this tip (SetupInitialDuo / AdvanceIfNeeded). + duo, err := s.registry.DuoAt(nextRoad) + if err != nil { + logger.Error("tipcut duo not in registry after avail CommitQC tip", + "road", nextRoad) + return fmt.Errorf("DuoAt(%d): %w", nextRoad, err) + } + nextDuo = duo + } + iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(qc)}, epochs: nextDuo}) } return nil } @@ -163,7 +202,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() { @@ -171,8 +210,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 (stale view). - 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 } @@ -214,7 +253,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/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 93d13f84ac..43c2f5094d 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -63,7 +63,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) @@ -77,9 +77,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{ @@ -99,9 +99,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)) @@ -123,7 +123,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]()) @@ -144,9 +144,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)) @@ -172,9 +172,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{ @@ -192,10 +192,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 { @@ -221,10 +221,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 { @@ -255,7 +255,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 @@ -278,7 +278,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 @@ -300,10 +300,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 { @@ -332,10 +332,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 { @@ -365,10 +365,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 { @@ -400,10 +400,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 { @@ -413,7 +413,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{ @@ -429,10 +429,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 { @@ -441,7 +441,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{ @@ -458,10 +458,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 { @@ -470,7 +470,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{ @@ -487,10 +487,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 { @@ -515,10 +515,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 { @@ -527,7 +527,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{ @@ -544,14 +544,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 { @@ -572,10 +572,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 { @@ -608,10 +608,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 { @@ -621,7 +621,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{ @@ -638,10 +638,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 { @@ -652,7 +652,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{ @@ -668,10 +668,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{ @@ -687,10 +687,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 { @@ -700,7 +700,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{ @@ -716,11 +716,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{ @@ -735,10 +735,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 { @@ -747,7 +747,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{ @@ -764,10 +764,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 { @@ -776,7 +776,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{ @@ -793,10 +793,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 { @@ -809,7 +809,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{ @@ -826,11 +826,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 { @@ -839,7 +839,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{ @@ -857,7 +857,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 @@ -870,62 +870,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. @@ -938,7 +939,7 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { // and terminates the consensus component (instead of panicking). dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) db := newTestBlockDB(t, filepath.Join(dir, "blockdb")) ds, err := data.NewState(&data.Config{Registry: registry}, db) if err != nil { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index a2231430c7..2d9228aad1 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -86,7 +86,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() @@ -115,7 +115,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() @@ -138,7 +138,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() @@ -166,7 +166,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() @@ -184,7 +184,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() @@ -203,7 +203,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() @@ -227,7 +227,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) @@ -261,7 +261,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() @@ -293,7 +293,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() @@ -337,7 +337,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() @@ -376,7 +376,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() @@ -402,7 +402,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() @@ -431,7 +431,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/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 46e947a672..e969dd7e44 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, Epochs: types.EpochDuo{Current: 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/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 583ec1f270..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" @@ -9,6 +10,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 +41,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 +119,7 @@ func newState( cfg: cfg, // metrics: NewMetrics(), avail: availState, + registry: data.Registry(), inner: utils.NewMutex(innerSend), innerRecv: innerSend.Subscribe(), persister: pers, @@ -123,16 +128,30 @@ 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, Epochs: initialInner.epochs}), myProposal: utils.NewAtomicSend(utils.None[*types.FullProposal]()), myPrepareVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqPrepareVote]()), myCommitVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqCommitVote]()), 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() @@ -169,7 +188,16 @@ 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() + // 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.epochs.Current.EpochIndex() { + logger.Debug("dropping prepare vote for non-current epoch", + "vote_epoch", uint64(voteEp), "current_epoch", uint64(i.epochs.Current.EpochIndex())) + return nil + } + committee := i.epochs.Current.Committee() if err := vote.VerifySig(committee); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } @@ -181,7 +209,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() + // Same Current-epoch contract as PushPrepareVote. + i := s.innerRecv.Load() + 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.epochs.Current.EpochIndex())) + return nil + } + committee := i.epochs.Current.Committee() if err := vote.VerifySig(committee); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } @@ -193,7 +228,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 + // Same Current-epoch contract as PushPrepareVote. + i := s.innerRecv.Load() + 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.epochs.Current.EpochIndex())) + return nil + } + ep := i.epochs.Current if err := vote.Verify(ep); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } @@ -210,7 +252,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. @@ -219,10 +261,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, @@ -253,7 +300,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) @@ -298,9 +345,9 @@ 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 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 { if qc, ok := last.Get(); ok { return s.pushCommitQC(qc) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 0606b4d20d..54b32e7b24 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" @@ -18,7 +20,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 := newTestDataState(registry) s := utils.OrPanic1(NewState(&Config{ Key: keys[0], @@ -83,7 +85,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) } @@ -113,7 +115,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) } @@ -170,7 +172,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) } @@ -183,7 +185,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) } @@ -227,7 +229,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) } @@ -256,7 +258,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 { @@ -269,7 +271,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { makeDataState := func() *data.State { return newTestDataState(registry) } 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 { @@ -319,3 +321,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 afdafabade..fdf362d123 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -53,6 +53,9 @@ type inner struct { appProposals map[types.GlobalBlockNumber]*types.AppProposal blockHashes map[types.BlockHeaderHash]types.GlobalBlockNumber + // Store under Watch.Lock; readers use State.epochDuo. + epochDuo utils.AtomicSend[types.EpochDuo] + // first is the exclusive low end of retained in-memory state: maps keep // [first, next*). Set by newInner / skipTo; advanced by evictBelowBound to // min(nextAppProposal, App.GlobalNumber()+1) when a CommitQC.App exists. @@ -98,12 +101,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() @@ -120,7 +119,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 (unlike insertQC, which verifies). // @@ -176,10 +175,11 @@ func (i *inner) updateNextBlock(m *metrics.Metrics) { // CommitQC.App appears. nextToExecute uses qc[nextAppProposal] (or the tip QC // when fully caught up), so it does not require retaining nextAppProposal-1. type State struct { - cfg *Config - metrics *metrics.Metrics - inner utils.Watch[*inner] - blockDB types.BlockDB + cfg *Config + metrics *metrics.Metrics + inner utils.Watch[*inner] + epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epochDuo; EpochDuo() reads it + blockDB types.BlockDB } // NewState constructs a data State, replaying persisted state from blockDB. @@ -195,12 +195,104 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { inner: utils.NewWatch(newInner(cfg.Registry.FirstBlock())), blockDB: blockDB, } + // Seed epochs before replay: data/ is the restart anchor, so SetupInitialDuo + // registers placeholder epochs for the whole retained CommitQC span (see + // epoch.Registry tip-interlock). EpochAt then resolves committees during + // replay; the live operating window (epochDuo) is centered on the tip below. + // + // TODO(autobahn): LastExecutedBlock comes from app.LastBlockHeight() (app + // state DB). Move execution tip into Giga storage; stop reading the app DB. + commitQCs, err := commitQCSpan(blockDB) + if err != nil { + return nil, fmt.Errorf("scan CommitQC span: %w", err) + } + lastExecuted, err := s.lastExecutedRoad(blockDB) + if err != nil { + return nil, fmt.Errorf("resolve last executed road: %w", err) + } + cfg.Registry.SetupInitialDuo(lastExecuted, commitQCs) + if err := s.loadFromBlockDB(blockDB); err != nil { return nil, fmt.Errorf("loadFromBlockDB: %w", err) } + + // Center the live operating window on the CommitQC tip (tipcut = Index+1), + // matching live PushQC after a boundary store. SetupInitialDuo already + // EnsureDuoAt(span.Last+1), so DuoAt succeeds. + for in := range s.inner.Lock() { + initRoad := types.RoadIndex(0) + if in.nextQC > in.first { + if lastQC := in.qcs[in.nextQC-1]; lastQC != nil { + initRoad = lastQC.QC().Proposal().Index() + 1 + } + } + initDuo, err := cfg.Registry.DuoAt(initRoad) + if err != nil { + return nil, fmt.Errorf("init epochDuo: %w", err) + } + in.epochDuo = utils.NewAtomicSend(initDuo) + s.epochDuo = in.epochDuo.Subscribe() + } return s, nil } +// commitQCSpan reads the first and last retained CommitQC proposal roads from +// blockDB. None when the store holds no QCs (fresh start). Used to seed +// SetupInitialDuo before replay. +func commitQCSpan(blockDB types.BlockDB) (utils.Option[epoch.CommitQCSpan], error) { + first, ok, err := boundaryQCRoad(blockDB, false) + if err != nil || !ok { + return utils.None[epoch.CommitQCSpan](), err + } + last, _, err := boundaryQCRoad(blockDB, true) + if err != nil { + return utils.None[epoch.CommitQCSpan](), err + } + return utils.Some(epoch.CommitQCSpan{First: first, Last: last}), nil +} + +// boundaryQCRoad returns the proposal road of the first (reverse=false) or last +// (reverse=true) retained CommitQC, or ok=false when the store holds no QCs. +func boundaryQCRoad(blockDB types.BlockDB, reverse bool) (types.RoadIndex, bool, error) { + it, err := blockDB.QCs(reverse) + if err != nil { + return 0, false, fmt.Errorf("open QC iterator: %w", err) + } + defer func() { _ = it.Close() }() + ok, err := it.Next() + if err != nil || !ok { + return 0, false, err + } + qc, err := it.QC() + if err != nil { + return 0, false, err + } + return qc.QC().Proposal().Index(), true, nil +} + +// lastExecutedRoad maps cfg.LastExecutedBlock to its covering CommitQC road via +// BlockDB. None when unset (0) or the covering QC was pruned — SetupInitialDuo +// then skips the AdvanceIfNeeded lookahead (the CommitQC tip still anchors the +// duo). +func (s *State) lastExecutedRoad(blockDB types.BlockDB) (utils.Option[types.RoadIndex], error) { + n := s.cfg.LastExecutedBlock + if n == 0 { + return utils.None[types.RoadIndex](), nil + } + opt, err := blockDB.ReadQCByBlockNumber(n) + if err != nil { + if errors.Is(err, types.ErrPruned) || errors.Is(err, types.ErrNotFound) { + return utils.None[types.RoadIndex](), nil + } + return utils.None[types.RoadIndex](), fmt.Errorf("read QC for executed block %d: %w", n, err) + } + qc, ok := opt.Get() + if !ok { + return utils.None[types.RoadIndex](), nil + } + return utils.Some(qc.QC().Proposal().Index()), nil +} + // loadFromBlockDB replays QCs and blocks from blockDB into s.inner. // Called from NewState before any goroutines are spawned; the lock is acquired // only to satisfy the Watch API. @@ -219,6 +311,10 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { // Each iterator position has its covering QC and an optional block. Missing // blocks are allowed only at the tail. BlockDB enforces other consistency; this // method only rejects a first QC before committee genesis. +// +// Epochs are seeded (SetupInitialDuo) by the caller before replay, so EpochAt +// resolves each retained QC's committee here. This is recovery, not live +// admission — PushQC/PushBlock use the epochDuo window, never EpochAt. func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { for in := range s.inner.Lock() { err := func() error { @@ -273,7 +369,11 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { // gr.First == n it still fires for the covering QC when the scan opened // inside that QC's range. insertQC clips it to [nextQC, gr.Next). lastQC = qc - if err := in.insertQC(s.cfg.Registry, qc); err != nil { + ep, err := s.cfg.Registry.EpochAt(qc.QC().Proposal().Index()) + if err != nil { + return fmt.Errorf("load QC from BlockDB: epoch lookup: %w", err) + } + if err := in.insertQC(qc, ep); err != nil { return fmt.Errorf("load QC from BlockDB: %w", err) } } @@ -287,9 +387,10 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { return fmt.Errorf("read block %d from BlockDB: %w", n, err) } blk := blkOpt.OrPanic(fmt.Sprintf("block %d absent at a HasBlock position", n)) - e, ok := s.cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) + storedQC := in.qcs[n] + e, err := s.cfg.Registry.EpochAt(storedQC.QC().Proposal().Index()) + if err != nil { + return fmt.Errorf("load block %d from BlockDB: epoch lookup: %w", n, err) } if err := blk.Verify(e.Committee()); err != nil { return fmt.Errorf("verify block %d from BlockDB: %w", n, err) @@ -326,6 +427,25 @@ func (s *State) FirstAppProposal() types.GlobalBlockNumber { panic("unreachable") } +// 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") +} + // insertBlocksByHash matches byHash against stored (already verified) QC // headers over gr ∩ [nextBlock, nextQC) and inserts hits. Advances nextBlock // when the contiguous prefix grows. Caller must hold inner's lock. @@ -346,11 +466,17 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma // 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 { - // 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()) + ep, err := s.epochDuo.Load().EpochForRoad(qc.QC().Proposal().Index()) + if err != nil { + return err } gr := qc.QC().GlobalRange() needQC, err := func() (bool, error) { @@ -373,6 +499,7 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("qc.Verify(): %w", err) } } + // 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 { @@ -381,13 +508,31 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("b.Verify(): %w", err) } } + // Boundary: resolve next duo off-lock before mutating nextQC + // (WaitForDuo), so a failed wait cannot strand the tip. + idx := qc.QC().Proposal().Index() + 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 + } + 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 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 && nextDuo != nil { + inner.epochDuo.Store(*nextDuo) + } ctrl.Updated() } if len(byHash) > 0 { @@ -425,8 +570,11 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC // The QC for n must already be present (guaranteed by PushQC ordering), unless // the height is already in the contiguous block prefix (n < nextBlock) — in // that case the block is dropped silently (already stored or executed/evicted). +// +// Same epochDuo admission as PushQC: 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 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 @@ -437,11 +585,11 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block return nil } // n in [nextBlock, nextQC): QC is contiguous in that range. - 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.epochDuo.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 { diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index 6e7ebe9e31..1348909b8e 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -27,7 +27,7 @@ func (db *recoveryStartBlockDB) Iterator(n types.GlobalBlockNumber) (types.Block // TestRecoveryEmpty verifies that NewState is a no-op on a fresh BlockDB. func TestRecoveryEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() fb := registry.FirstBlock() @@ -45,7 +45,7 @@ func TestRecoveryEmpty(t *testing.T) { func TestNewStateInMemoryMode(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) state := utils.OrPanic1(NewState(&Config{Registry: registry}, memblock.NewBlockDB())) @@ -70,7 +70,7 @@ func TestNewStateInMemoryMode(t *testing.T) { // from BlockDB on restart. func TestRecoveryNormal(t *testing.T) { 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]()) @@ -248,7 +248,7 @@ func TestRecoveryLeavesAppTipBelowPruneFloorUnreadable(t *testing.T) { func TestPruningDiscards(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) @@ -283,7 +283,7 @@ func TestPruningDiscards(t *testing.T) { // BlockDB only contains data from a later QC range (as left by pruning + GC). func TestRecoveryAfterPruning(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) @@ -330,7 +330,7 @@ func TestRecoveryAfterPruning(t *testing.T) { func TestRecoveryBlocksBehind(t *testing.T) { ctx := t.Context() 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]()) @@ -378,7 +378,7 @@ func TestRecoveryBlocksBehind(t *testing.T) { // leave the blockless prefix inside [first, nextBlock), which inner's density invariant forbids. func TestRecoveryPartialQCPrefix(t *testing.T) { 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]()) @@ -430,7 +430,7 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { // floor set by the QC pass, so the "block predates first QC start" guard never fires. func TestRecoveryAfterPruneNoGC(t *testing.T) { 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]()) @@ -474,7 +474,7 @@ func TestRecoveryAfterPruneNoGC(t *testing.T) { // cursor sits at the QC start with nextBlock at that floor and no block data. func TestRecoveryQCsNoBlocks(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) @@ -506,7 +506,7 @@ func TestRecoveryQCsNoBlocks(t *testing.T) { func TestRunPersistSeedsFromRecoveryFloor(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) @@ -558,7 +558,7 @@ func TestRunPersistSeedsFromRecoveryFloor(t *testing.T) { // propagates.) func TestRecoveryBlockGap(t *testing.T) { 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]()) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 65842dcd38..c2452bbb29 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -104,7 +104,7 @@ func pushAppHashesRunning(ctx context.Context, state *State, rng utils.Rng, firs 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 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) s.SpawnBgNamed("state.Run()", func() error { @@ -176,7 +176,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 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -221,7 +221,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 { @@ -284,7 +284,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 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push qc1 with NO blocks — only the QC is stored. @@ -327,7 +327,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 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) s.SpawnBgNamed("state.Run()", func() error { @@ -369,7 +369,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 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -388,7 +388,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 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -429,7 +429,7 @@ func TestGlobalBlockByHash(t *testing.T) { func TestPushQCBeforeRunPersistsToBlockDB(t *testing.T) { ctx := t.Context() 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]()) @@ -481,7 +481,7 @@ func TestPushQCBeforeRunPersistsToBlockDB(t *testing.T) { func TestEvictionWaitsForCommitQCApp(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() @@ -551,7 +551,7 @@ func TestEvictionWaitsForCommitQCApp(t *testing.T) { func TestNextToExecuteAfterAppEviction(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() @@ -615,7 +615,7 @@ func TestNextToExecuteAfterAppEviction(t *testing.T) { func TestPruningKeepsLastQCRange(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() @@ -674,7 +674,7 @@ func TestPruningKeepsLastQCRange(t *testing.T) { func TestPruningWithPartialQCRange(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) @@ -756,7 +756,7 @@ func TestPushBlockWaitsForQC(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -806,7 +806,7 @@ func TestPushBlockWaitsForQC(t *testing.T) { func TestTryBlockHidesGapFills(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) diff --git a/sei-tendermint/internal/autobahn/data/testonly.go b/sei-tendermint/internal/autobahn/data/testonly.go index 2595b3a25a..b6a14ad601 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)) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 75191764d1..5600fb63ee 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -1,12 +1,36 @@ 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 @@ -14,23 +38,164 @@ 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: +// +// - 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 +// E-1 until E-1 is fully pruned — otherwise FullCommitQC export would skip +// still-queued roads (data/ cannot gap). +// +// 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 — 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: +// +// - data/ is the anchor (SetupInitialDuo only from data.NewState). +// - 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). +// - 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). +// - 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] + state utils.RWMutex[*registryState] + // 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. +// 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, genesisTimestamp time.Time, ) (*Registry, error) { - ep := types.NewEpoch(0, types.OpenRoadRange(), genesisTimestamp, committee, firstBlock) - return &Registry{ - state: utils.NewRWMutex(registryState{ + 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}, latest: 0, }), - }, nil + highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), + } + // 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[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 (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; +// 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. +// +// 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. +// 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 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 + } + haveWindow = true + } + + if road, ok := lastExecutedRoad.Get(); ok { + 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(span.Last))) + } else { + tipEpoch := IndexForRoad(road) + if !haveWindow { + // Execution-only: open Prev|Current around the executed tip. + windowFirst = 0 + if tipEpoch >= 1 { + windowFirst = tipEpoch - 1 + } + windowLast = tipEpoch + haveWindow = true + } + executedForAdvance = utils.Some(road) + } + } + + if !haveWindow { + windowFirst, windowLast = 0, 1 // fresh start + } + + for s := range r.state.Lock() { + for idx := windowFirst; idx <= windowLast; idx++ { + if _, ok := s.m[idx]; ok { + continue + } + _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present + } + } + + 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) + } } // FirstBlock returns the first global block number of the genesis epoch. @@ -42,42 +207,124 @@ func (r *Registry) FirstBlock() types.GlobalBlockNumber { panic("unreachable") } -// GenesisTimestamp returns the timestamp of the genesis epoch. -func (r *Registry) GenesisTimestamp() time.Time { +// EpochAt returns the epoch for the given road index. +// 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 := IndexForRoad(roadIndex) for s := range r.state.RLock() { - return s.m[0].FirstTimestamp() + if ep, ok := s.m[epochIdx]; ok { + return ep, nil + } + return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) } panic("unreachable") } -// EpochByIndex returns the epoch with the given index, if it exists. -func (r *Registry) EpochByIndex(idx types.EpochIndex) (*types.Epoch, bool) { - for s := range r.state.RLock() { - ep, ok := s.m[idx] - return ep, ok +// makeEpoch constructs a new epoch at epochIdx using the genesis committee and +// 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] + if !ok { + return nil, fmt.Errorf("genesis epoch missing from registry") } - panic("unreachable") + 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. + if epochIdx > r.highestEpoch.Load() { + r.highestEpoch.Store(epochIdx) + } + return epoch, nil } -// LatestEpoch returns the most recently activated epoch. -func (r *Registry) LatestEpoch() *types.Epoch { +// EnsureEpoch registers a genesis-committee placeholder for idx if missing. +func (r *Registry) EnsureEpoch(idx types.EpochIndex) { for s := range r.state.RLock() { - return s.m[s.latest] + if _, ok := s.m[idx]; ok { + return + } + } + for s := range r.state.Lock() { + if _, ok := s.m[idx]; !ok { + _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present + } } - 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 +// 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 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) { + 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. +// +// 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) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { + 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(FirstRoad(centerIdx - 1)); err == nil { + duo.Prev = utils.Some(prev) } - return []*types.Epoch{ep}, nil } - panic("unreachable") + return duo, nil +} + +// 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) WaitForDuo(ctx context.Context, roadIndex types.RoadIndex) (types.EpochDuo, error) { + if duo, err := r.DuoAt(roadIndex); err == nil { + return duo, nil + } + centerIdx := IndexForRoad(roadIndex) + if _, err := r.highestEpoch.Subscribe().Wait(ctx, func(highest types.EpochIndex) bool { + return highest >= centerIdx + }); err != nil { + return types.EpochDuo{}, err + } + 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 f5249bcd44..94a2eaabc1 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) { @@ -20,20 +21,248 @@ func makeRegistry(t *testing.T) (*Registry, *types.Committee) { return r, committee } -func TestRegistry_EpochByIndex_UnknownReturnsNotFound(t *testing.T) { +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) + if err != nil { + t.Fatalf("EpochAt(0): %v", err) + } + rng := ep.RoadRange() + if rng.First != 0 || rng.Next != FirstRoad(1) { + t.Fatalf("genesis RoadRange = {%d, %d}, want {0, %d}", rng.First, rng.Next, FirstRoad(1)) + } +} + +func TestEpochAt_GenesisEpoch(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) 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(LastRoad(0)) + if err != nil { + t.Fatalf("EpochAt(LastRoad(0)) error: %v", err) } if ep.EpochIndex() != 0 { - t.Fatalf("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(FirstRoad(2)) + if err == nil { + t.Fatal("EpochAt(FirstRoad(2)) expected error for unregistered epoch, got nil") + } +} + +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(FirstRoad(2)); err == nil { + t.Fatal("AdvanceIfNeeded(0) must not seed epoch 2") + } + r.AdvanceIfNeeded(LastRoad(0)) + ep, err := r.EpochAt(FirstRoad(2)) + if err != nil { + t.Fatalf("EpochAt(FirstRoad(2)) after last road of epoch 0: %v", err) + } + if ep.EpochIndex() != 2 { + t.Fatalf("EpochAt(FirstRoad(2)).EpochIndex() = %d, want 2", ep.EpochIndex()) + } +} + +func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { + r, _ := makeRegistry(t) + // 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) + } + } + if _, err := r.EpochAt(FirstRoad(6)); err == nil { + t.Fatal("EpochAt(epoch 6) should not be present from CommitQC alone") + } +} + +func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { + r, _ := makeRegistry(t) + // 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) + } + } + 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_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) + 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) + } + } + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present after mid-epoch execution") + } +} + +func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { + r, _ := makeRegistry(t) + 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) + } + } + if _, err := r.EpochAt(FirstRoad(8)); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present from closing-road execution") + } +} + +func TestSetupInitialDuo_ExecutionPastCommitQCIgnored(t *testing.T) { + r, _ := makeRegistry(t) + 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) + } + } + 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) + // 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) + } + } + // 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") + } +} + +func TestDuoAt_GenesisEpoch(t *testing.T) { + r, _ := makeRegistry(t) + duo, err := r.DuoAt(0) + if err != nil { + t.Fatalf("DuoAt(0) error: %v", err) + } + if duo.Prev.IsPresent() { + t.Fatalf("DuoAt(0).Prev = %v, want absent for epoch 0", duo.Prev) + } + if duo.Current == nil || duo.Current.EpochIndex() != 0 { + t.Fatalf("DuoAt(0).Current.EpochIndex() wrong, want 0") + } +} + +func TestDuoAt_MiddleEpoch(t *testing.T) { + r, _ := makeRegistry(t) + 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) + } + prev, ok := duo.Prev.Get() + if !ok || prev.EpochIndex() != 1 { + t.Fatalf("DuoAt(epoch 2).Prev.EpochIndex() wrong, want 1") + } + if duo.Current == nil || duo.Current.EpochIndex() != 2 { + t.Fatalf("DuoAt(epoch 2).Current.EpochIndex() wrong, want 2") + } +} + +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: FirstRoad(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.DuoAt(FirstRoad(1)) + if err == nil { + t.Fatal("DuoAt(FirstRoad(1)) expected error when Current epoch not registered, got nil") + } +} + +func TestWaitForDuo_FastPathAndWait(t *testing.T) { + r, _ := makeRegistry(t) + // 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()) + + // Tipcut into epoch 2 needs epoch 2 registered (seeded by executing epoch 0). + tip := FirstRoad(2) + _, err = r.DuoAt(tip) + require.Error(t, err) + + type result struct { + duo types.EpochDuo + err error } + done := make(chan result, 1) + go func() { + duo, err := r.WaitForDuo(t.Context(), tip) + done <- result{duo, err} + }() + 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 c02099160f..7e0229b0d8 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]. +// 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] 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 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(FirstRoad(startEpoch)) 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,18 @@ 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)) + 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 f2560b2b4b..6e5fb4539e 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -231,7 +231,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}, memblock.NewBlockDB())) consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ Key: keys[0], diff --git a/sei-tendermint/internal/p2p/giga/avail.go b/sei-tendermint/internal/p2p/giga/avail.go index b6a09ccdb3..e4f77ffafb 100644 --- a/sei-tendermint/internal/p2p/giga/avail.go +++ b/sei-tendermint/internal/p2p/giga/avail.go @@ -244,7 +244,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) } } diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 247809fbb9..8caeb0e719 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -18,7 +18,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 0da59b5ea4..48d1fa8df4 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -97,7 +97,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 ff8996c683..4aba087117 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -49,6 +49,27 @@ type gigaRouterCommon struct { inboundFullnodeCap int64 } +// 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, constructs the committee, and // returns an initialised data.State backed by blockDB. // @@ -259,6 +280,18 @@ 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 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 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 { + return nil, fmt.Errorf("r.data.QC(%v): %w", b.GlobalNumber, err) + } + if b.GlobalNumber+1 == qc.QC().GlobalRange().Next { + r.data.Registry().AdvanceIfNeeded(qc.QC().Proposal().Index()) + } return commitResp, nil } @@ -527,6 +560,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.data.EpochDuo().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 890b899845..7bcd17c8f7 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -9,7 +9,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" "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" ) @@ -23,16 +22,7 @@ type gigaFullnodeRouter struct { func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey, dataState *data.State) (*gigaFullnodeRouter, error) { 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, - 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 5742291d7c..506c1d8ba5 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" @@ -11,11 +12,25 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" "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" ) +// 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 @@ -38,22 +53,16 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey, dataSta 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{ - gigaRouterCommon: &gigaRouterCommon{ - cfg: &cfg.GigaRouterCommonConfig, - key: key, - data: dataState, - 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 } @@ -91,7 +100,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { // shards, we proxy only while the target validator is currently connected; // otherwise we keep the tx local as a best-effort availability heuristic. func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := r.data.EpochDuo().Current.Committee().EvmShard(sender) if r.validatorKey == shardValidator { return utils.None[*url.URL]() } diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 90c641a4dc..f1d078cb52 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -15,6 +15,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" 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/p2p/giga" @@ -204,6 +205,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) } + // 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(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.FirstRoad(1)) + require.NoError(t, err, "initial seeding should have registered epoch 1") return nil }) require.NoError(t, err) @@ -344,3 +351,11 @@ func TestGigaRouter_EvmProxy(t *testing.T) { }) require.NoError(t, err) } + +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 c12aa050531830dfbd69e890ae3719d4880cb97b Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 22 Jul 2026 20:39:09 -0700 Subject: [PATCH 02/85] fix(autobahn): clarify restart seed when execution leads tip into N+1 (CON-358) Avail/consensus may lead data when lastExecuted is already in the CommitQC tip epoch; EnsureAfterExecuted seeds N+1. Document that path and add regression tests so lead tips do not rely on soft-heal. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 3 +- .../internal/autobahn/avail/state_test.go | 16 +++- .../internal/autobahn/consensus/inner.go | 2 +- .../internal/autobahn/consensus/inner_test.go | 31 +++++++ .../internal/autobahn/consensus/state_test.go | 91 +++++++++++++++++++ .../internal/autobahn/epoch/registry.go | 19 ++-- .../internal/autobahn/epoch/registry_test.go | 15 +++ 7 files changed, 162 insertions(+), 15 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 2016b3ec69..dcc58d040f 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -133,8 +133,7 @@ func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded uti // Restore persisted CommitQCs. Tipcut insert above may already hold the // 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. + // Epoch must already be seeded (data.SetupInitialDuo); missing epoch is a hard error. for _, lqc := range l.commitQCs { if lqc.Index < i.commitQCs.next { continue diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 9aa0a63983..33f03cde0a 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1430,18 +1430,24 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { require.True(t, state.LastAppQC().IsPresent()) } -// TestRestartDuoFromCommitTipNeedsSetup covers restart seeding: DuoAt at the -// 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. +// TestRestartDuoFromCommitTipNeedsSetup: DuoAt at FirstRoad(2) needs epoch 2. +// CommitQC alone in epoch 1 is not enough for SetupInitialDuo without execution; +// lead tips do not soft-seed. Also checks newInner still requires a prune +// anchor when Current > 0 (after SetupInitialDuo with execution). func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} tip2 := epoch.FirstRoad(2) _, err := registry.DuoAt(tip2) require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") +} - // CommitQC closing epoch 1 opens {1} then tipcut/execution add 2 and 3. +// TestRestartDuoFromCommitTipNeedsSetup_ExecutionPath: closing CommitQC in +// epoch 1 plus execution tip still use SetupInitialDuo (data anchor). +func TestRestartDuoFromCommitTipNeedsSetup_ExecutionPath(t *testing.T) { + rng := utils.TestRng() + registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} + tip2 := epoch.FirstRoad(2) tip1 := epoch.LastRoad(1) registry.SetupInitialDuo( utils.Some(tip1), diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 01410eccb2..fe4560561e 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. - // Epoch seeding is owned by data.NewState (SetupInitialDuo); EpochAt hard-fails + // 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) diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 43c2f5094d..d36b8428ee 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -72,6 +72,37 @@ func TestNewInnerEmpty(t *testing.T) { require.False(t, i.TimeoutVote.IsPresent(), "timeoutVote should be None") } +// TestNewInner_ConsensusTipLeadsDataWindow: data CommitQC tip is in epoch 1; +// LastExecutedBlock in the same epoch means N-1 is done → EnsureAfterExecuted +// seeds epoch 2. Persisted CommitQC is LastRoad(1) → view tipcut FirstRoad(2). +func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 3) // {0,1} only + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + closing := epoch.LastRoad(1) + tipcut := epoch.FirstRoad(2) + + // data.NewState: LastExecutedBlock in epoch 1 (same as CommitQC tip) → seed 2. + registry.EnsureAfterExecuted(epoch.FirstRoad(1)) + + vote := types.NewCommitVote(types.ProposalAt(ep1, types.View{Index: closing})) + votes := make([]*types.Signed[*types.CommitVote], len(keys)) + for i, k := range keys { + votes[i] = types.Sign(k, vote) + } + cqc := types.NewCommitQC(votes) + + i, err := newInner(utils.Some(innerProtoConv.Encode(&persistedInner{ + CommitQC: utils.Some(cqc), + })), registry) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(2), i.epochs.Current.EpochIndex()) + require.Equal(t, tipcut, i.View().Index) + prev, ok := i.epochs.Prev.Get() + require.True(t, ok) + require.Equal(t, types.EpochIndex(1), prev.EpochIndex()) +} + func TestNewInnerPrepareVote(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 54b32e7b24..2e81c7ef2f 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -345,3 +345,94 @@ func TestNewState_AvailBehindConsensus(t *testing.T) { }))) require.ErrorIs(t, err, ErrAvailBehindConsensus) } + +// commitQCAtRoad builds a minimal valid CommitQC at idx under ep (ProposalAt tipcut). +func commitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadIndex) *types.CommitQC { + vote := types.NewCommitVote(types.ProposalAt(ep, types.View{Index: idx})) + votes := make([]*types.Signed[*types.CommitVote], len(keys)) + for i, k := range keys { + votes[i] = types.Sign(k, vote) + } + return types.NewCommitQC(votes) +} + +// fullCommitQCAtRoad wraps commitQCAtRoad with matching headers for data WAL PersistQC. +func fullCommitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadIndex) *types.FullCommitQC { + lane := ep.Committee().Lanes().At(0) + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() + cqc := commitQCAtRoad(ep, keys, idx) + return types.NewFullCommitQC(cqc, []*types.BlockHeader{header}) +} + +// TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1 is the end-to-end restart +// path for tip interlocking: data CommitQC WAL tip stays in epoch N while +// avail/consensus tips are already at FirstRoad(N+1). LastExecutedBlock is in +// the same epoch as the data tip → N-1 is done → SetupInitialDuo seeds N+1. +func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // NewRegistry → {0,1} + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + n := types.EpochIndex(1) + nPlus1 := types.EpochIndex(2) + + dataDir := t.TempDir() + stateDir := t.TempDir() // shared by consensus "inner" + avail "avail_inner" + + // Data tip in epoch N: last loaded CommitQC at FirstRoad(N); tipcut stays in N. + dataRoad := epoch.FirstRoad(n) + dataQC := fullCommitQCAtRoad(ep1, keys, dataRoad) + dw := utils.OrPanic1(data.NewDataWAL(utils.Some(dataDir), registry.FirstBlock())) + require.NoError(t, dw.CommitQCs.PersistQC(dataQC)) + require.NoError(t, dw.Close()) + + closingRoad := epoch.LastRoad(n) // seal of N → tipcut FirstRoad(N+1) + closingQC := commitQCAtRoad(ep1, keys, closingRoad) + gr := closingQC.GlobalRange() + appQC := types.NewAppQC(func() []*types.Signed[*types.AppVote] { + p := types.NewAppProposal(gr.First, closingRoad, types.GenAppHash(rng), ep1.EpochIndex()) + votes := make([]*types.Signed[*types.AppVote], len(keys)) + for i, k := range keys { + votes[i] = types.Sign(k, types.NewAppVote(p)) + } + return votes + }()) + + // Avail tipcut in epoch N+1 via prune-anchor CommitQC at LastRoad(N). + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(stateDir), "avail_inner") + require.NoError(t, err) + require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ + AppQc: types.AppQCConv.Encode(appQC), + CommitQc: types.CommitQCConv.Encode(closingQC), + })) + + // Consensus tipcut in epoch N+1 (view after LastRoad(N)). + seedPersistedInner(stateDir, &persistedInner{CommitQC: utils.Some(closingQC)}) + + leadTip := epoch.FirstRoad(nPlus1) + // Same epoch as data tip → EnsureAfterExecuted seeds N+1. + lastExec := dataQC.QC().GlobalRange().First + dw2 := utils.OrPanic1(data.NewDataWAL(utils.Some(dataDir), registry.FirstBlock())) + ds := utils.OrPanic1(data.NewState(&data.Config{ + Registry: registry, + LastExecutedBlock: lastExec, + }, dw2)) + dataTip := ds.CommitTipCut() + require.Equal(t, n, epoch.IndexForRoad(dataTip), "data tipcut must stay in epoch N") + _, err = registry.EpochAt(leadTip) + require.NoError(t, err, "executed in data-tip epoch must seed N+1") + + cs, err := NewState(&Config{ + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + PersistentStateDir: utils.Some(stateDir), + }, ds) + require.NoError(t, err) + + consTip := cs.CommitTipCut() + availTip := cs.Avail().CommitTipCut() + require.Equal(t, leadTip, consTip) + require.Equal(t, leadTip, availTip) + require.Equal(t, nPlus1, epoch.IndexForRoad(consTip)) + require.GreaterOrEqual(t, consTip, dataTip, "consensus may lead data") + require.GreaterOrEqual(t, availTip, consTip, "avail may lead consensus") +} diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 5600fb63ee..01b0a23c57 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -73,14 +73,18 @@ type registryState struct { // // Restart: // -// - data/ is the anchor (SetupInitialDuo only from data.NewState). +// - data/ is the sole restart seeder (SetupInitialDuo from data.NewState). +// Avail/consensus tips may lead data (async FullCommitQC→data.PushQC), but +// do not seed the registry. Lead into an unseeded epoch → EpochAt/DuoAt +// hard-fail (no soft-heal). When lastExecuted is already in the CommitQC +// tip's epoch N, EnsureAfterExecuted seeds N+1 (N-1 is done) — that is how +// a tipcut in N+1 stays inside the reconstructed window. // - 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). -// - 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. +// - SetupInitialDuo may register genesis-committee placeholders (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). @@ -266,8 +270,9 @@ func (r *Registry) EnsureDuoAt(road types.RoadIndex) { } // 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. +// would have produced once road was executed. If execution has reached epoch E +// (same epoch as a CommitQC tip in E), epoch E-1 is done → seed E+1. Closing +// road of E additionally seeds E+2 (AdvanceIfNeeded: finishing M → M+2). func (r *Registry) EnsureAfterExecuted(road types.RoadIndex) { tipEpoch := IndexForRoad(road) r.EnsureEpoch(tipEpoch + 1) diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 94a2eaabc1..09e038a795 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -161,6 +161,21 @@ func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { } } +// TestSetupInitialDuo_ExecutedSameEpochAsTipSeedsNext: CommitQC tip in epoch N; +// LastExecuted also in N ⇒ N-1 done ⇒ EnsureAfterExecuted seeds N+1. +func TestSetupInitialDuo_ExecutedSameEpochAsTipSeedsNext(t *testing.T) { + r, _ := makeRegistry(t) // {0,1} + n := types.EpochIndex(1) + tip := FirstRoad(n) + r.SetupInitialDuo(utils.Some(tip), utils.Some(CommitQCSpan{First: tip, Last: tip})) + if _, err := r.EpochAt(FirstRoad(n + 1)); err != nil { + t.Fatalf("EpochAt(N+1) when executed in tip epoch N: %v", err) + } + if _, err := r.EpochAt(FirstRoad(n + 2)); err == nil { + t.Fatal("EpochAt(N+2) should not be present from mid-N execution") + } +} + func TestSetupInitialDuo_ExecutionPastCommitQCIgnored(t *testing.T) { r, _ := makeRegistry(t) tip := midRoad(3) From 9dfa24cfcd82db06bd6189e6c03b19dec6ce6631 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 22 Jul 2026 20:52:55 -0700 Subject: [PATCH 03/85] docs(autobahn): note >1-epoch tip lead hard-fails on restart (CON-358) Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/epoch/registry.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 01b0a23c57..979fe5d1fc 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -78,7 +78,12 @@ type registryState struct { // do not seed the registry. Lead into an unseeded epoch → EpochAt/DuoAt // hard-fail (no soft-heal). When lastExecuted is already in the CommitQC // tip's epoch N, EnsureAfterExecuted seeds N+1 (N-1 is done) — that is how -// a tipcut in N+1 stays inside the reconstructed window. +// a tipcut in N+1 stays inside the reconstructed window. Leading by more +// than one epoch should not happen: sealing N+1 needs registry N+2, which +// needs execution of LastRoad(N), which needs that CommitQC in data — so if +// data tip is still in N, avail/consensus cannot have entered N+2. If it +// did, EpochAt/DuoAt hard-fails (corrupt / inconsistent local state; do +// 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. From e6bfb4cebaa035328ebd882e86a007b4ddd41ed1 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 22 Jul 2026 21:37:25 -0700 Subject: [PATCH 04/85] refactor(autobahn): rename waitForAppQCEpoch to waitPruneLeash (CON-358) Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 17 +++++++++-------- .../internal/autobahn/avail/state_test.go | 10 +++++----- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 92732319de..d62f8b5340 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -299,10 +299,11 @@ func (s *State) admitRoadOrDrop( return ep, nil } -// waitForAppQCEpoch blocks until latest AppQC is from epochIdx or later. -// 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 { +// waitPruneLeash blocks until latest AppQC is from epochIdx or later (prune +// leash: N-1 must be fully pruned before sealing N drops Prev). incoming, if +// present, is the AppQC on this PushAppQC call; it counts before prune updates +// latestAppQC. +func (s *State) waitPruneLeash(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 { @@ -335,7 +336,7 @@ func (s *State) waitForAppQCEpoch(ctx context.Context, epochIdx types.EpochIndex // 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. +// toward the prune leash. Epoch 0 has no prior leash. // See epoch.Registry tip-interlock docs. func (s *State) waitCommitEpochLeashes( ctx context.Context, @@ -347,11 +348,11 @@ func (s *State) waitCommitEpochLeashes( return nil } // 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 { + // Prune leash: AppQC in N ⇒ N-1 fully pruned before it leaves the duo. + if err := s.waitPruneLeash(ctx, epochIdx, incoming); err != nil { return err } - // N+1 existing: execution finished N-1 (usually AdvanceIfNeeded). + // Execution leash: 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 33f03cde0a..9b4276e258 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -421,7 +421,7 @@ func TestStateMismatchedQCs(t *testing.T) { }) } -func TestWaitForAppQCEpoch(t *testing.T) { +func TestWaitPruneLeash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) @@ -434,7 +434,7 @@ func TestWaitForAppQCEpoch(t *testing.T) { timeout, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() - require.ErrorIs(t, state.waitForAppQCEpoch(timeout, 0, utils.None[*types.AppQC]()), context.DeadlineExceeded) + require.ErrorIs(t, state.waitPruneLeash(timeout, 0, utils.None[*types.AppQC]()), context.DeadlineExceeded) lane := keys[0].Public() b, err := state.ProduceLocalBlock(state.NextBlock(lane), types.GenPayload(rng)) @@ -451,14 +451,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, utils.None[*types.AppQC]()) }() + go func() { done <- state.waitPruneLeash(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, utils.None[*types.AppQC]())) + require.NoError(t, state.waitPruneLeash(ctx, 0, utils.None[*types.AppQC]())) timeout2, cancel2 := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel2() - require.ErrorIs(t, state.waitForAppQCEpoch(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) + require.ErrorIs(t, state.waitPruneLeash(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) } func TestPushBlockRejectsBadParentHash(t *testing.T) { From 66604d65c5ba1650dc671e015b2dbbe476cb6229 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 22 Jul 2026 21:56:48 -0700 Subject: [PATCH 05/85] fix(autobahn): count LaneVotes with live duo after epoch advance (CON-358) PushVote verified off-lock then could race advanceEpoch/reweight; under the lock, drop signers that left Current and weight with the live committee. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 15 +++- .../internal/autobahn/avail/state_test.go | 89 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index d62f8b5340..6a29f3e6e3 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -662,8 +662,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 { - // One duo snapshot for verify and count: a boundary advanceEpoch between - // those steps must not leave them on different Current committees. + // Verify off-lock against a duo snapshot (crypto off the hot lock). duo := s.epochDuo.Load() c := duo.Current.Committee() if err := vote.Msg().Verify(c); err != nil { @@ -672,6 +671,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote if err := vote.VerifySig(c); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } + verifiedEpoch := duo.Current.EpochIndex() h := vote.Msg().Header() for inner, ctrl := range s.inner.Lock() { ls, ok := inner.lanes[h.Lane()] @@ -684,13 +684,22 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote }); err != nil { return err } + // WaitUntil may release the lock; a boundary advanceEpoch can reweight + // vote sets under a new Current. Count only with the live duo — if + // Current advanced, drop signers that left the committee (sig already + // checked; no re-VerifySig). + live := inner.epochDuo.Load() + if live.Current.EpochIndex() != verifiedEpoch && + !live.Current.Committee().HasReplica(vote.Key()) { + return nil + } if h.BlockNumber() < q.first { return nil } for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - if q.q[h.BlockNumber()].pushVote(duo.Current, vote).IsPresent() { + if q.q[h.BlockNumber()].pushVote(live.Current, vote).IsPresent() { ctrl.Updated() } } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 9b4276e258..f15134bad8 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "testing" + "testing/synctest" "time" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/memblock" @@ -461,6 +462,94 @@ func TestWaitPruneLeash(t *testing.T) { require.ErrorIs(t, state.waitPruneLeash(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) } +// TestPushVote_DropsSignerAfterEpochAdvance: PushVote verifies off-lock, then +// WaitUntil releases the lock; advanceEpoch installs a Current that excludes the +// signer — the vote must be dropped (no re-VerifySig; HasReplica only). +func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + 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 := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + ep0 := state.epochDuo.Load().Current + lane := keys[0].Public() + n := types.BlockNumber(BlocksPerLane) // WaitUntil: n >= persistedBlockStart+BlocksPerLane + header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(keys[0], types.NewLaneVote(header)) + + weights := map[types.PublicKey]uint64{} + for _, k := range keys[1:] { + weights[k.Public()] = 1000 + } + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, + ep0.FirstTimestamp(), utils.OrPanic1(types.NewCommittee(weights)), ep0.FirstBlock()) + duo1 := types.EpochDuo{Prev: utils.Some(ep0), Current: ep1} + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() // blocked in WaitUntil (capacity) + + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(duo1) + inner.lanes[lane].persistedBlockStart = 1 // unblock: n < 1+BlocksPerLane + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + require.Equal(t, types.EpochIndex(1), inner.epochDuo.Load().Current.EpochIndex()) + require.Equal(t, types.BlockNumber(0), inner.lanes[lane].votes.next, + "dropped vote must not extend the queue") + } + }) +} + +// TestPushVote_CountsSignerAfterEpochAdvance: same WaitUntil race window, but the +// new Current still includes the signer — count with live committee weights. +func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + 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 := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + ep0 := state.epochDuo.Load().Current + lane := keys[0].Public() + n := types.BlockNumber(BlocksPerLane) + header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(keys[0], types.NewLaneVote(header)) + + weights := map[types.PublicKey]uint64{keys[0].Public(): 1000, keys[1].Public(): 1000} + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, + ep0.FirstTimestamp(), utils.OrPanic1(types.NewCommittee(weights)), ep0.FirstBlock()) + duo1 := types.EpochDuo{Prev: utils.Some(ep0), Current: ep1} + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() + + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(duo1) + inner.lanes[lane].persistedBlockStart = 1 + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + ls := inner.lanes[lane] + require.Contains(t, ls.votes.q[n].byKey, keys[0].Public()) + require.Equal(t, uint64(1000), ls.votes.q[n].byHash[header.Hash()].weight) + } + }) +} + func TestPushBlockRejectsBadParentHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() From b11b749124d24733e296024e630cff26417a1c88 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 12:41:30 -0700 Subject: [PATCH 06/85] fix(autobahn): migrate leftover tests from DataWAL to BlockDB (CON-358) Post-rebase cleanup: later commits still constructed NewDataWAL after main replaced it with BlockDB. Co-authored-by: Cursor --- .../internal/autobahn/avail/state_test.go | 6 ++---- .../internal/autobahn/consensus/inner_test.go | 2 +- .../internal/autobahn/consensus/state_test.go | 21 +++++++++---------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index f15134bad8..8315d1776a 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -470,8 +470,7 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { ctx := t.Context() 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())))) + ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) ep0 := state.epochDuo.Load().Current @@ -515,8 +514,7 @@ func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { ctx := t.Context() 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())))) + ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) ep0 := state.epochDuo.Load().Current diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index d36b8428ee..53f77fdd6a 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -918,7 +918,7 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { commitVote := types.Sign(keys[0], types.NewCommitVote(currentProposal)) timeoutVote := types.NewFullTimeoutVote(keys[0], types.View{Index: 6, Number: 0}, utils.Some(prepareQC)) - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + ds := newTestDataState(registry) cs, err := newState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 2e81c7ef2f..b48e44619f 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -325,10 +325,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { 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())), - )) + ds := newTestDataState(registry) 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)) @@ -356,7 +353,7 @@ func commitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadIndex return types.NewCommitQC(votes) } -// fullCommitQCAtRoad wraps commitQCAtRoad with matching headers for data WAL PersistQC. +// fullCommitQCAtRoad wraps commitQCAtRoad with matching headers for BlockDB WriteQC. func fullCommitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadIndex) *types.FullCommitQC { lane := ep.Committee().Lanes().At(0) header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() @@ -365,7 +362,7 @@ func fullCommitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadI } // TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1 is the end-to-end restart -// path for tip interlocking: data CommitQC WAL tip stays in epoch N while +// path for tip interlocking: data CommitQC BlockDB tip stays in epoch N while // avail/consensus tips are already at FirstRoad(N+1). LastExecutedBlock is in // the same epoch as the data tip → N-1 is done → SetupInitialDuo seeds N+1. func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { @@ -381,9 +378,11 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { // Data tip in epoch N: last loaded CommitQC at FirstRoad(N); tipcut stays in N. dataRoad := epoch.FirstRoad(n) dataQC := fullCommitQCAtRoad(ep1, keys, dataRoad) - dw := utils.OrPanic1(data.NewDataWAL(utils.Some(dataDir), registry.FirstBlock())) - require.NoError(t, dw.CommitQCs.PersistQC(dataQC)) - require.NoError(t, dw.Close()) + db1 := newTestBlockDB(t, dataDir) + grQC := dataQC.QC().GlobalRange() + require.NoError(t, db1.WriteQC(grQC.First, grQC.Next, dataQC)) + require.NoError(t, db1.Flush()) + require.NoError(t, db1.Close()) closingRoad := epoch.LastRoad(n) // seal of N → tipcut FirstRoad(N+1) closingQC := commitQCAtRoad(ep1, keys, closingRoad) @@ -411,11 +410,11 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { leadTip := epoch.FirstRoad(nPlus1) // Same epoch as data tip → EnsureAfterExecuted seeds N+1. lastExec := dataQC.QC().GlobalRange().First - dw2 := utils.OrPanic1(data.NewDataWAL(utils.Some(dataDir), registry.FirstBlock())) + db2 := newTestBlockDB(t, dataDir) ds := utils.OrPanic1(data.NewState(&data.Config{ Registry: registry, LastExecutedBlock: lastExec, - }, dw2)) + }, db2)) dataTip := ds.CommitTipCut() require.Equal(t, n, epoch.IndexForRoad(dataTip), "data tipcut must stay in epoch N") _, err = registry.EpochAt(leadTip) From b1ac945fb33a3e7df8be819b22dbb52f04b0c21f Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 15:15:44 -0700 Subject: [PATCH 07/85] refactor(autobahn): seed restart epochs from half-open CommitQC RoadRange (CON-358) Use Option[RoadRange] tipcuts, IsLastBlock for exec completion, and hard-fail on pruned exec tips or empty commit ranges instead of soft-healing. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 4 + .../autobahn/types/proposal_test.go | 8 + .../internal/autobahn/avail/state_test.go | 14 +- .../internal/autobahn/consensus/inner_test.go | 4 +- .../internal/autobahn/consensus/state_test.go | 15 +- .../internal/autobahn/data/state.go | 51 +++--- .../internal/autobahn/epoch/registry.go | 166 ++++++++---------- .../internal/autobahn/epoch/registry_test.go | 87 ++++++--- .../internal/autobahn/epoch/testonly.go | 22 ++- .../internal/p2p/giga_router_common.go | 8 +- .../p2p/giga_router_validator_test.go | 13 +- 11 files changed, 229 insertions(+), 163 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 00c061d3ee..39acc3c6fa 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -77,6 +77,10 @@ func (g GlobalRange) Has(n GlobalBlockNumber) bool { return g.First <= n && n < g.Next } +func (g GlobalRange) IsLastBlock(n GlobalBlockNumber) bool { + return n+1 == g.Next +} + // RoadIndex is the index of the consensus instance. type RoadIndex uint64 diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 4f8e8f7304..f4caea18ff 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -71,6 +71,14 @@ func makeAppQCFor(keys []SecretKey, globalNum GlobalBlockNumber, roadIdx RoadInd return NewAppQC(votes) } +func TestGlobalRange_IsLastBlock(t *testing.T) { + g := GlobalRange{First: 10, Next: 13} // covers 10, 11, 12 + require.False(t, g.IsLastBlock(10)) + require.False(t, g.IsLastBlock(11)) + require.True(t, g.IsLastBlock(12)) + require.False(t, g.IsLastBlock(13)) +} + func TestProposalVerifyRejectsEmptyTipcut(t *testing.T) { rng := utils.TestRng() committee, _ := GenCommittee(rng, 4) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 8315d1776a..683befeedc 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -32,7 +32,7 @@ var ( func registerDuoAtEpoch(s *State, n types.EpochIndex) { r := s.data.Registry() tip := epoch.FirstRoad(n) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(epoch.CommitQCSpan{First: tip, Last: tip})) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) duo := utils.OrPanic1(r.DuoAt(tip)) for inner := range s.inner.Lock() { inner.epochDuo.Store(duo) @@ -1518,9 +1518,9 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { } // TestRestartDuoFromCommitTipNeedsSetup: DuoAt at FirstRoad(2) needs epoch 2. -// CommitQC alone in epoch 1 is not enough for SetupInitialDuo without execution; -// lead tips do not soft-seed. Also checks newInner still requires a prune -// anchor when Current > 0 (after SetupInitialDuo with execution). +// CommitQC alone in epoch 1 is not enough without an execution tipcut that +// advances past N-1; lead tips do not soft-seed. Also checks newInner still +// requires a prune anchor when Current > 0 (after SetupInitialDuo with execution). func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} @@ -1536,10 +1536,8 @@ func TestRestartDuoFromCommitTipNeedsSetup_ExecutionPath(t *testing.T) { registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} tip2 := epoch.FirstRoad(2) tip1 := epoch.LastRoad(1) - registry.SetupInitialDuo( - utils.Some(tip1), - utils.Some(epoch.CommitQCSpan{First: tip1, Last: tip1}), - ) + // Fully executed LastRoad(1) → execution tipcut FirstRoad(2). + registry.SetupInitialDuo(utils.Some(tip1+1), utils.Some(types.RoadRange{First: tip1, Next: tip1 + 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_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 53f77fdd6a..53b50454c2 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -73,7 +73,7 @@ func TestNewInnerEmpty(t *testing.T) { } // TestNewInner_ConsensusTipLeadsDataWindow: data CommitQC tip is in epoch 1; -// LastExecutedBlock in the same epoch means N-1 is done → EnsureAfterExecuted +// LastExecutedBlock in the same epoch means N-1 is done → EnsureExecTipcut // seeds epoch 2. Persisted CommitQC is LastRoad(1) → view tipcut FirstRoad(2). func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { rng := utils.TestRng() @@ -83,7 +83,7 @@ func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { tipcut := epoch.FirstRoad(2) // data.NewState: LastExecutedBlock in epoch 1 (same as CommitQC tip) → seed 2. - registry.EnsureAfterExecuted(epoch.FirstRoad(1)) + registry.EnsureExecTipcut(epoch.FirstRoad(1)) // execution tipcut still on first road of epoch 1 vote := types.NewCommitVote(types.ProposalAt(ep1, types.View{Index: closing})) votes := make([]*types.Signed[*types.CommitVote], len(keys)) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index b48e44619f..4fcb0bcde6 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -367,7 +367,17 @@ func fullCommitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadI // the same epoch as the data tip → N-1 is done → SetupInitialDuo seeds N+1. func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) // NewRegistry → {0,1} + // FirstBlock must be >0: nextRoadToExecute treats LastExecutedBlock 0 as unset. + sks := utils.GenSliceN(rng, 4, types.GenSecretKey) + weights := map[types.PublicKey]uint64{} + for _, sk := range sks { + weights[sk.Public()] = 1000 + } + committee := utils.OrPanic1(types.NewCommittee(weights)) + registry := utils.OrPanic1(epoch.NewRegistry(committee, 1, time.Time{})) + registry.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadRange]()) + registry.EnsureEpoch(1) + keys := sks ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) n := types.EpochIndex(1) nPlus1 := types.EpochIndex(2) @@ -408,8 +418,9 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { seedPersistedInner(stateDir, &persistedInner{CommitQC: utils.Some(closingQC)}) leadTip := epoch.FirstRoad(nPlus1) - // Same epoch as data tip → EnsureAfterExecuted seeds N+1. + // Same epoch as data tip → EnsureExecTipcut seeds N+1. lastExec := dataQC.QC().GlobalRange().First + require.Positive(t, lastExec, "LastExecutedBlock 0 is the unset sentinel") db2 := newTestBlockDB(t, dataDir) ds := utils.OrPanic1(data.NewState(&data.Config{ Registry: registry, diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index fdf362d123..f6d688f14a 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -206,11 +206,11 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { if err != nil { return nil, fmt.Errorf("scan CommitQC span: %w", err) } - lastExecuted, err := s.lastExecutedRoad(blockDB) + nextRoad, err := s.nextRoadToExecute(blockDB) if err != nil { - return nil, fmt.Errorf("resolve last executed road: %w", err) + return nil, fmt.Errorf("resolve next road to execute: %w", err) } - cfg.Registry.SetupInitialDuo(lastExecuted, commitQCs) + cfg.Registry.SetupInitialDuo(nextRoad, commitQCs) if err := s.loadFromBlockDB(blockDB); err != nil { return nil, fmt.Errorf("loadFromBlockDB: %w", err) @@ -218,7 +218,7 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { // Center the live operating window on the CommitQC tip (tipcut = Index+1), // matching live PushQC after a boundary store. SetupInitialDuo already - // EnsureDuoAt(span.Last+1), so DuoAt succeeds. + // EnsureDuoAt(Next) when a span is present, so DuoAt succeeds. for in := range s.inner.Lock() { initRoad := types.RoadIndex(0) if in.nextQC > in.first { @@ -236,19 +236,21 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { return s, nil } -// commitQCSpan reads the first and last retained CommitQC proposal roads from -// blockDB. None when the store holds no QCs (fresh start). Used to seed -// SetupInitialDuo before replay. -func commitQCSpan(blockDB types.BlockDB) (utils.Option[epoch.CommitQCSpan], error) { +// commitQCSpan returns the half-open [First, Next) of retained CommitQC roads, +// or None when the store holds no QCs. Used to seed SetupInitialDuo. +func commitQCSpan(blockDB types.BlockDB) (utils.Option[types.RoadRange], error) { first, ok, err := boundaryQCRoad(blockDB, false) if err != nil || !ok { - return utils.None[epoch.CommitQCSpan](), err + return utils.None[types.RoadRange](), err } - last, _, err := boundaryQCRoad(blockDB, true) + last, ok, err := boundaryQCRoad(blockDB, true) if err != nil { - return utils.None[epoch.CommitQCSpan](), err + return utils.None[types.RoadRange](), err } - return utils.Some(epoch.CommitQCSpan{First: first, Last: last}), nil + if !ok { + return utils.None[types.RoadRange](), fmt.Errorf("CommitQC span: first present but last missing") + } + return utils.Some(types.RoadRange{First: first, Next: last + 1}), nil } // boundaryQCRoad returns the proposal road of the first (reverse=false) or last @@ -270,11 +272,16 @@ func boundaryQCRoad(blockDB types.BlockDB, reverse bool) (types.RoadIndex, bool, return qc.QC().Proposal().Index(), true, nil } -// lastExecutedRoad maps cfg.LastExecutedBlock to its covering CommitQC road via -// BlockDB. None when unset (0) or the covering QC was pruned — SetupInitialDuo -// then skips the AdvanceIfNeeded lookahead (the CommitQC tip still anchors the -// duo). -func (s *State) lastExecutedRoad(blockDB types.BlockDB) (utils.Option[types.RoadIndex], error) { +// nextRoadToExecute returns a half-open execution tipcut: the next RoadIndex +// that still needs execution. Covering CommitQC road R for LastExecutedBlock: +// mid-range → R; fully finished (IsLastBlock) → R+1. Same tipcut shape as +// CommitQC Index+1; registry EnsureExecTipcut maps tipcut → last completed +// road. +// +// LastExecutedBlock 0 means fresh/unknown (Config default; giga only sets it +// when app.LastBlockHeight() > 0) → None. Missing/pruned covering QC for a +// positive height is an error (inconsistent app tip vs BlockDB). +func (s *State) nextRoadToExecute(blockDB types.BlockDB) (utils.Option[types.RoadIndex], error) { n := s.cfg.LastExecutedBlock if n == 0 { return utils.None[types.RoadIndex](), nil @@ -282,15 +289,19 @@ func (s *State) lastExecutedRoad(blockDB types.BlockDB) (utils.Option[types.Road opt, err := blockDB.ReadQCByBlockNumber(n) if err != nil { if errors.Is(err, types.ErrPruned) || errors.Is(err, types.ErrNotFound) { - return utils.None[types.RoadIndex](), nil + return utils.None[types.RoadIndex](), fmt.Errorf("covering QC for executed block %d missing or pruned", n) } return utils.None[types.RoadIndex](), fmt.Errorf("read QC for executed block %d: %w", n, err) } qc, ok := opt.Get() if !ok { - return utils.None[types.RoadIndex](), nil + return utils.None[types.RoadIndex](), fmt.Errorf("covering QC for executed block %d missing or pruned", n) + } + road := qc.QC().Proposal().Index() + if qc.QC().GlobalRange().IsLastBlock(n) { + return utils.Some(road + 1), nil } - return utils.Some(qc.QC().Proposal().Index()), nil + return utils.Some(road), nil } // loadFromBlockDB replays QCs and blocks from blockDB into s.inner. diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 979fe5d1fc..3abf4ac98e 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -74,12 +74,15 @@ type registryState struct { // Restart: // // - data/ is the sole restart seeder (SetupInitialDuo from data.NewState). -// Avail/consensus tips may lead data (async FullCommitQC→data.PushQC), but -// do not seed the registry. Lead into an unseeded epoch → EpochAt/DuoAt -// hard-fail (no soft-heal). When lastExecuted is already in the CommitQC -// tip's epoch N, EnsureAfterExecuted seeds N+1 (N-1 is done) — that is how -// a tipcut in N+1 stays inside the reconstructed window. Leading by more -// than one epoch should not happen: sealing N+1 needs registry N+2, which +// NewRegistry only installs epoch 0; empty BlockDB passes None commit span +// (genesis only). Avail/consensus tips may lead data (async FullCommitQC→ +// data.PushQC), but do not seed the registry. Lead into an unseeded epoch → +// EpochAt/DuoAt hard-fail (no soft-heal). When the execution tipcut is already +// in the CommitQC tip's epoch N, EnsureExecTipcut seeds N+1 (N-1 is done) — that +// is how a tipcut in N+1 stays inside the reconstructed window. Seeding N+2 still +// requires the execution tipcut to be past LastRoad(N) (fully finished that +// road → tipcut LastRoad(N)+1); AdvanceIfNeeded owns the LastRoad check. +// Leading by more than one epoch should not happen: sealing N+1 needs registry N+2, which // needs execution of LastRoad(N), which needs that CommitQC in data — so if // data tip is still in N, avail/consensus cannot have entered N+2. If it // did, EpochAt/DuoAt hard-fails (corrupt / inconsistent local state; do @@ -89,7 +92,8 @@ type registryState struct { // (together ⇒ avail >= data). Behind → hard-fail. // - SetupInitialDuo may register genesis-committee placeholders (temporary; // real committees TBD) — that is not inventing roads or repairing -// inconsistent tips. +// inconsistent tips. data.NewState errors if a positive LastExecutedBlock +// has no covering CommitQC. Empty CommitQC ranges are rejected. // - 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). @@ -104,106 +108,75 @@ type Registry struct { highestEpoch utils.AtomicSend[types.EpochIndex] } -// NewRegistry creates a Registry with the genesis committee. -// Initial seeding registers epochs {0,1} (both defined by genesis); epoch 2 is -// registered when the last road of epoch 0 is executed (AdvanceIfNeeded). +// NewRegistry creates a Registry with the genesis committee (epoch 0 only). +// Epoch 1+ placeholders are seeded by data.NewState via SetupInitialDuo. func NewRegistry( committee *types.Committee, firstBlock types.GlobalBlockNumber, genesisTimestamp time.Time, ) (*Registry, error) { ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, genesisTimestamp, committee, firstBlock) - r := &Registry{ + return &Registry{ state: utils.NewRWMutex(®istryState{ m: map[types.EpochIndex]*types.Epoch{0: ep}, latest: 0, }), highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), - } - // 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[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 + }, nil } // SetupInitialDuo seeds placeholder epochs on restart. Called only from // 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; -// 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. +// 1. commitQCs — half-open retained CommitQC road range [First, Next). Seeds +// every epoch covering [First, Next), then EnsureDuoAt(Next). None = empty +// store (genesis epoch 0 only). Empty range (First >= Next) panics. +// 2. nextRoadToExecute — half-open execution tipcut; EnsureExecTipcut +// restores AdvanceIfNeeded lookahead. None = nothing fully executed yet. +// Ignored when past commit tipcut (Next). Present without a commit span +// panics (inconsistent: execution cannot lead an empty CommitQC store). // -// 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. +// Idempotent for existing entries. // -// TODO(autobahn): lastExecutedRoad is derived from app.LastBlockHeight() (Cosmos +// TODO(autobahn): nextRoadToExecute 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. +// seeding — persist that in Giga storage alongside CommitQC/AppQC. // 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]() - +func (r *Registry) SetupInitialDuo( + nextRoadToExecute utils.Option[types.RoadIndex], + commitQCs utils.Option[types.RoadRange], +) { 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 + if span.First >= span.Next { + panic(fmt.Sprintf("SetupInitialDuo: empty CommitQC range [%d, %d)", span.First, span.Next)) } - haveWindow = true - } + windowFirst := IndexForRoad(span.First) + windowLast := IndexForRoad(span.Next - 1) - if road, ok := lastExecutedRoad.Get(); ok { - 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(span.Last))) - } else { - tipEpoch := IndexForRoad(road) - if !haveWindow { - // Execution-only: open Prev|Current around the executed tip. - windowFirst = 0 - if tipEpoch >= 1 { - windowFirst = tipEpoch - 1 + for s := range r.state.Lock() { + for idx := windowFirst; idx <= windowLast; idx++ { + if _, ok := s.m[idx]; ok { + continue } - windowLast = tipEpoch - haveWindow = true + r.makeEpoch(s, idx) } - executedForAdvance = utils.Some(road) } - } + r.EnsureDuoAt(span.Next) - if !haveWindow { - windowFirst, windowLast = 0, 1 // fresh start - } - - for s := range r.state.Lock() { - for idx := windowFirst; idx <= windowLast; idx++ { - if _, ok := s.m[idx]; ok { - continue + if next, ok := nextRoadToExecute.Get(); ok { + if next > span.Next { + logger.Warn("execution tipcut past CommitQC tipcut on restart; ignoring for epoch seeding", + slog.Uint64("execution_tipcut", uint64(next)), + slog.Uint64("commit_qc_tipcut", uint64(span.Next))) + } else { + r.EnsureExecTipcut(next) } - _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present } + return } - 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) + if nextRoadToExecute.IsPresent() { + panic("execution tipcut without CommitQC span on restart") } } @@ -233,11 +206,12 @@ 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. 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) { +// Note: does NOT advance s.latest. Panics if genesis (epoch 0) is missing — +// NewRegistry always installs it. +func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types.Epoch { genesis, ok := s.m[0] if !ok { - return nil, fmt.Errorf("genesis epoch missing from registry") + panic("genesis epoch missing from registry") } firstRoad := FirstRoad(epochIdx) epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) @@ -247,7 +221,7 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) (*type if epochIdx > r.highestEpoch.Load() { r.highestEpoch.Store(epochIdx) } - return epoch, nil + return epoch } // EnsureEpoch registers a genesis-committee placeholder for idx if missing. @@ -259,7 +233,7 @@ func (r *Registry) EnsureEpoch(idx types.EpochIndex) { } for s := range r.state.Lock() { if _, ok := s.m[idx]; !ok { - _, _ = r.makeEpoch(s, idx) //nolint:errcheck // genesis always present + r.makeEpoch(s, idx) } } } @@ -274,23 +248,25 @@ func (r *Registry) EnsureDuoAt(road types.RoadIndex) { r.EnsureEpoch(center) } -// EnsureAfterExecuted restores the registry lookahead live AdvanceIfNeeded -// would have produced once road was executed. If execution has reached epoch E -// (same epoch as a CommitQC tip in E), epoch E-1 is done → seed E+1. Closing -// road of E additionally seeds E+2 (AdvanceIfNeeded: finishing M → M+2). -func (r *Registry) EnsureAfterExecuted(road types.RoadIndex) { - tipEpoch := IndexForRoad(road) - r.EnsureEpoch(tipEpoch + 1) - if road == LastRoad(tipEpoch) { - r.EnsureEpoch(tipEpoch + 2) +// EnsureExecTipcut restores registry lookahead for a half-open execution tipcut +// (next road that still needs execution). Roads strictly below next are fully +// done — same shape as a CommitQC tipcut. next==0 means nothing completed yet. +// Last completed road is next-1; AdvanceIfNeeded owns whether that road is +// LastRoad(epoch). +func (r *Registry) EnsureExecTipcut(next types.RoadIndex) { + if next == 0 { + return } + lastDone := next - 1 + r.EnsureEpoch(IndexForRoad(lastDone) + 1) + r.AdvanceIfNeeded(lastDone) } -// 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. +// AdvanceIfNeeded seeds epoch M+2 when roadIndex is LastRoad(M) (design: finishing +// N-1 registers N+1, i.e. M=N-1 → M+2=N+1). Call only after the last global of +// that road has executed (GlobalRange.IsLastBlock) — the live path and data's +// execution tipcut (road+1 when complete) share that gate; this function owns +// the LastRoad(epoch) check. Earlier roads in the epoch are a no-op. // 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) { diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 09e038a795..162d9b9e1b 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -84,11 +84,32 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { } } +func TestSetupInitialDuo_EmptyNoneNoneKeepsGenesis(t *testing.T) { + r, _ := makeRegistry(t) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadRange]()) + if _, err := r.EpochAt(0); err != nil { + t.Fatalf("EpochAt(0) after empty seeding: %v", err) + } + if _, err := r.EpochAt(FirstRoad(1)); err == nil { + t.Fatal("EpochAt(epoch 1) should not be present from empty None/None seeding") + } +} + +func TestSetupInitialDuo_EmptyRangePanics(t *testing.T) { + r, _ := makeRegistry(t) + defer func() { + if recover() == nil { + t.Fatal("empty CommitQC range must panic") + } + }() + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: 5, Next: 5})) +} + func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { r, _ := makeRegistry(t) // Single tip mid-5 + tipcut EnsureDuoAt → {4,5}. tip := midRoad(5) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(CommitQCSpan{First: tip, Last: tip})) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) 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) @@ -103,7 +124,7 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { r, _ := makeRegistry(t) // Closing tip: {5} + tipcut FirstRoad(6) → {5,6}. tip := LastRoad(5) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(CommitQCSpan{First: tip, Last: tip})) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) 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) @@ -117,25 +138,31 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { } } -func TestSetupInitialDuo_WALSpan(t *testing.T) { +func TestSetupInitialDuo_CommitSpanFromFirst(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)})) + // Span mid-2..mid-5 → seed epochs 2..5 only (not open from 0). + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{ + First: midRoad(2), + Next: midRoad(5) + 1, + })) 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) + t.Fatalf("EpochAt(epoch %d) after commit span seeding: %v", idx, err) } } + if _, err := r.EpochAt(FirstRoad(1)); err == nil { + t.Fatal("EpochAt(epoch 1) should not be present when span.First is in epoch 2") + } if _, err := r.EpochAt(FirstRoad(6)); err == nil { - t.Fatal("EpochAt(epoch 6) should not be present from mid-epoch WAL span") + t.Fatal("EpochAt(epoch 6) should not be present from mid-epoch commit tipcut") } } func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { r, _ := makeRegistry(t) 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})) + // Tip mid-5 + CommitQC tipcut → {4,5}; execution tipcut mid-5 → lastDone mid-5-1 → epoch 6. + r.SetupInitialDuo(utils.Some(tip), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) 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) @@ -149,8 +176,8 @@ func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { r, _ := makeRegistry(t) tip := LastRoad(5) - // Closing execution: EnsureAfterExecuted → 6 and 7; tipcut adds 6. - r.SetupInitialDuo(utils.Some(tip), utils.Some(CommitQCSpan{First: tip, Last: tip})) + // Closing road fully executed → execution tipcut tip+1 → AdvanceIfNeeded → 6 and 7. + r.SetupInitialDuo(utils.Some(tip+1), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) 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) @@ -161,13 +188,30 @@ func TestSetupInitialDuo_ExecutionClosingAddsNextNext(t *testing.T) { } } +// TestSetupInitialDuo_MidClosingRoadDoesNotSeedNextNext: execution tipcut still +// on LastRoad(E) (mid-range) must seed E+1 only — tipcut not past the road, so +// lastDone is not LastRoad(E). Mid-road crash must not unlock E+2. +func TestSetupInitialDuo_MidClosingRoadDoesNotSeedNextNext(t *testing.T) { + r, _ := makeRegistry(t) + tip := LastRoad(5) + r.SetupInitialDuo(utils.Some(tip), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + for _, idx := range []types.EpochIndex{5, 6} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after mid-closing execution: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) must not be present when LastRoad(E) is only mid-executed") + } +} + // TestSetupInitialDuo_ExecutedSameEpochAsTipSeedsNext: CommitQC tip in epoch N; -// LastExecuted also in N ⇒ N-1 done ⇒ EnsureAfterExecuted seeds N+1. +// LastExecuted also in N ⇒ N-1 done ⇒ EnsureExecTipcut seeds N+1. func TestSetupInitialDuo_ExecutedSameEpochAsTipSeedsNext(t *testing.T) { - r, _ := makeRegistry(t) // {0,1} + r, _ := makeRegistry(t) // {0} n := types.EpochIndex(1) tip := FirstRoad(n) - r.SetupInitialDuo(utils.Some(tip), utils.Some(CommitQCSpan{First: tip, Last: tip})) + r.SetupInitialDuo(utils.Some(tip), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) if _, err := r.EpochAt(FirstRoad(n + 1)); err != nil { t.Fatalf("EpochAt(N+1) when executed in tip epoch N: %v", err) } @@ -179,8 +223,8 @@ func TestSetupInitialDuo_ExecutedSameEpochAsTipSeedsNext(t *testing.T) { func TestSetupInitialDuo_ExecutionPastCommitQCIgnored(t *testing.T) { r, _ := makeRegistry(t) 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})) + // Commit tip mid-3; execution mid-5 past tipcut → warn, ignore. + r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) 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) @@ -193,14 +237,17 @@ func TestSetupInitialDuo_ExecutionPastCommitQCIgnored(t *testing.T) { func TestSetupInitialDuo_ExecutionDoesNotShrinkCommitWindow(t *testing.T) { r, _ := makeRegistry(t) - // 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)})) + // Commit span mid-2..mid-5; lagging execution mid-3 must not drop 4/5. + r.SetupInitialDuo(utils.Some(midRoad(3)), utils.Some(types.RoadRange{ + First: midRoad(2), + Next: midRoad(5) + 1, + })) 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) } } - // Lagging mid-3 EnsureAfterExecuted → epoch 4 only; not 6. + // Lagging mid-3 EnsureExecTipcut → 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") } @@ -223,7 +270,7 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { func TestDuoAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) tip := midRoad(2) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(CommitQCSpan{First: tip, Last: tip})) + r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) duo, err := r.DuoAt(FirstRoad(2)) if err != nil { t.Fatalf("DuoAt(epoch 2) error: %v", err) diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 7e0229b0d8..eb14523b38 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -27,7 +27,9 @@ func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.E weights[sk.Public()] = 1000 + uint64(rng.Intn(1000)) //nolint:gosec } committee := utils.OrPanic1(types.NewCommittee(weights)) - firstBlock := types.GenGlobalBlockNumber(rng) % 1000000 + // FirstBlock is a global height. Keep 0 so empty-store tipcuts stay on + // road indices that only need epochs {0,1} — matching production genesis. + const firstBlock types.GlobalBlockNumber = 0 // 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 @@ -47,18 +49,26 @@ func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Regis weights[sk.Public()] = 1000 + uint64(rng.Intn(1000)) //nolint:gosec } committee := utils.OrPanic1(types.NewCommittee(weights)) - firstBlock := types.GenGlobalBlockNumber(rng) % 1000000 + const firstBlock types.GlobalBlockNumber = 0 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())) + // Same empty-BlockDB default as data.NewState: None/None (genesis only). + registry.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadRange]()) + // Ensure at least {0,1} so DuoAt(FirstRoad(1)) works in tests. + through := startEpoch + if through < 1 { + through = 1 + } for s := range registry.state.Lock() { - if startEpoch > 0 { - utils.OrPanic1(registry.makeEpoch(s, startEpoch-1)) + for idx := types.EpochIndex(0); idx <= through; idx++ { + if _, ok := s.m[idx]; ok { + continue + } + registry.makeEpoch(s, idx) } - // Always seed startEpoch itself (no-op when startEpoch==0, genesis already exists). - utils.OrPanic1(registry.makeEpoch(s, startEpoch)) 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 4aba087117..4826225cdf 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -281,15 +281,15 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } // 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 tipcuts are rejected - // by Proposal.Verify, so every closing road has a last global. + // IsLastBlock matches data.nextRoadToExecute advancing to road+1; + // AdvanceIfNeeded owns LastRoad(epoch). 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 { return nil, fmt.Errorf("r.data.QC(%v): %w", b.GlobalNumber, err) } - if b.GlobalNumber+1 == qc.QC().GlobalRange().Next { + if qc.QC().GlobalRange().IsLastBlock(b.GlobalNumber) { 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 f1d078cb52..8d1914a5bb 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -205,12 +205,13 @@ 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) } - // 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(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.FirstRoad(1)) - require.NoError(t, err, "initial seeding should have registered epoch 1") + // Short run stays in early epoch 0. Empty/early CommitQC span only seeds + // epochs covering retained QCs + EnsureDuoAt(Next); AdvanceIfNeeded only + // seeds N+2 on the last road. Epoch 1+ must still be absent. + _, err := giga0.data.Registry().EpochAt(epoch.FirstRoad(1)) + require.Error(t, err, "epoch 1 should not be seeded before tipcut/execution leaves epoch 0") + _, err = giga0.data.Registry().EpochAt(epoch.FirstRoad(0)) + require.NoError(t, err, "genesis epoch 0 must remain registered") return nil }) require.NoError(t, err) From b648fb76ece9fd109b8c4563e0435acdd34a5591 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 16:46:06 -0700 Subject: [PATCH 08/85] fix(autobahn): seed epoch 1 on empty BlockDB restart (CON-358) Empty SetupInitialDuo left only genesis epoch 0, so sealing the last road of epoch 0 blocked forever on WaitForDuo(FirstRoad(1)). Seed the genesis neighbor {0,1} so fresh chains and upgrade-module CI can advance. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/epoch/registry.go | 11 ++++++++--- .../internal/autobahn/epoch/registry_test.go | 12 +++++++----- sei-tendermint/internal/autobahn/epoch/testonly.go | 2 +- .../internal/p2p/giga_router_validator_test.go | 11 +++++------ 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 3abf4ac98e..630a9a6010 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -74,8 +74,9 @@ type registryState struct { // Restart: // // - data/ is the sole restart seeder (SetupInitialDuo from data.NewState). -// NewRegistry only installs epoch 0; empty BlockDB passes None commit span -// (genesis only). Avail/consensus tips may lead data (async FullCommitQC→ +// NewRegistry only installs epoch 0; empty BlockDB seeds genesis neighbor +// {0,1} via EnsureDuoAt(FirstRoad(1)) so sealing epoch 0 can WaitForDuo. +// Avail/consensus tips may lead data (async FullCommitQC→ // data.PushQC), but do not seed the registry. Lead into an unseeded epoch → // EpochAt/DuoAt hard-fail (no soft-heal). When the execution tipcut is already // in the CommitQC tip's epoch N, EnsureExecTipcut seeds N+1 (N-1 is done) — that @@ -130,7 +131,8 @@ func NewRegistry( // // 1. commitQCs — half-open retained CommitQC road range [First, Next). Seeds // every epoch covering [First, Next), then EnsureDuoAt(Next). None = empty -// store (genesis epoch 0 only). Empty range (First >= Next) panics. +// store: seed genesis neighbor {0,1} via EnsureDuoAt(FirstRoad(1)) so sealing +// epoch 0 can WaitForDuo(FirstRoad(1)). Empty range (First >= Next) panics. // 2. nextRoadToExecute — half-open execution tipcut; EnsureExecTipcut // restores AdvanceIfNeeded lookahead. None = nothing fully executed yet. // Ignored when past commit tipcut (Next). Present without a commit span @@ -178,6 +180,9 @@ func (r *Registry) SetupInitialDuo( if nextRoadToExecute.IsPresent() { panic("execution tipcut without CommitQC span on restart") } + // Fresh chain: AdvanceIfNeeded(LastRoad(0)) only seeds epoch 2; sealing + // epoch 0 WaitForDuo(FirstRoad(1)) needs epoch 1 registered first. + r.EnsureDuoAt(FirstRoad(1)) } // FirstBlock returns the first global block number of the genesis epoch. diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 162d9b9e1b..7409e978c5 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -84,14 +84,16 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { } } -func TestSetupInitialDuo_EmptyNoneNoneKeepsGenesis(t *testing.T) { +func TestSetupInitialDuo_EmptyNoneNoneSeedsGenesisNeighbor(t *testing.T) { r, _ := makeRegistry(t) r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadRange]()) - if _, err := r.EpochAt(0); err != nil { - t.Fatalf("EpochAt(0) after empty seeding: %v", err) + for _, idx := range []types.EpochIndex{0, 1} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after empty seeding: %v", idx, err) + } } - if _, err := r.EpochAt(FirstRoad(1)); err == nil { - t.Fatal("EpochAt(epoch 1) should not be present from empty None/None seeding") + if _, err := r.EpochAt(FirstRoad(2)); err == nil { + t.Fatal("EpochAt(epoch 2) should not be present from empty None/None seeding") } } diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index eb14523b38..2d07ec410c 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -55,7 +55,7 @@ func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Regis func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumber, startEpoch types.EpochIndex) *Registry { registry := utils.OrPanic1(NewRegistry(committee, firstBlock, time.Now())) - // Same empty-BlockDB default as data.NewState: None/None (genesis only). + // Same empty-BlockDB default as data.NewState: None/None seeds {0,1}. registry.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadRange]()) // Ensure at least {0,1} so DuoAt(FirstRoad(1)) works in tests. through := startEpoch diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 8d1914a5bb..a2d1edf3d2 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -205,13 +205,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) } - // Short run stays in early epoch 0. Empty/early CommitQC span only seeds - // epochs covering retained QCs + EnsureDuoAt(Next); AdvanceIfNeeded only - // seeds N+2 on the last road. Epoch 1+ must still be absent. + // Empty-store SetupInitialDuo seeds genesis neighbor {0,1}; short run + // never finishes LastRoad(0), so AdvanceIfNeeded must not seed epoch 2. _, err := giga0.data.Registry().EpochAt(epoch.FirstRoad(1)) - require.Error(t, err, "epoch 1 should not be seeded before tipcut/execution leaves epoch 0") - _, err = giga0.data.Registry().EpochAt(epoch.FirstRoad(0)) - require.NoError(t, err, "genesis epoch 0 must remain registered") + require.NoError(t, err, "empty-store seeding should register epoch 1 for WaitForDuo") + _, err = giga0.data.Registry().EpochAt(epoch.FirstRoad(2)) + require.Error(t, err, "epoch 2 should not be seeded before last road of epoch 0") return nil }) require.NoError(t, err) From 1d8bfd6f524b0b9bb8e8564e645dfd691b59e6f1 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 17:05:51 -0700 Subject: [PATCH 09/85] fix(autobahn): resolve AppProposal epoch by road in giga test (CON-358) TestConsensusClientServer used LatestEpoch() while iterating from road 0, so GenRegistry startEpoch=1 made want/got diverge. Match production IndexForRoad. Co-authored-by: Cursor --- sei-tendermint/internal/p2p/giga/consensus_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index 6cd33adac2..0f4de7cb08 100644 --- a/sei-tendermint/internal/p2p/giga/consensus_test.go +++ b/sei-tendermint/internal/p2p/giga/consensus_test.go @@ -41,11 +41,12 @@ func TestConsensusClientServer(t *testing.T) { GlobalNumber: idx, FinalAppState: wantAppProposal, } + road := types.RoadIndex(offset) p := types.NewAppProposal( idx, - types.RoadIndex(offset), + road, types.GenAppHash(rng), - registry.LatestEpoch().EpochIndex(), + epoch.IndexForRoad(road), ) wantAppProposal = utils.Some(p) for _, n := range nodes { From 55a41d4d620c6cb987f612630828111b32f03fdb Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 17:35:51 -0700 Subject: [PATCH 10/85] refactor(autobahn): require non-nil Current via NewEpochDuo (CON-358) Reject empty EpochDuo construction; panic on nil Current and drop defensive nil guards so callers use NewEpochDuo for valid windows. Co-authored-by: Cursor --- .../block/blocksim/block_generator.go | 2 +- sei-tendermint/autobahn/types/epoch_duo.go | 13 +++- .../autobahn/types/epoch_duo_test.go | 29 ++++--- .../autobahn/types/proposal_test.go | 78 +++++++++---------- sei-tendermint/autobahn/types/testonly.go | 2 +- .../autobahn/types/wireguard_test.go | 2 +- .../internal/autobahn/avail/inner_test.go | 2 +- .../internal/autobahn/avail/state_test.go | 6 +- .../internal/autobahn/consensus/inner.go | 5 +- .../autobahn/consensus/persisted_inner.go | 2 +- .../internal/autobahn/data/state_test.go | 2 +- .../internal/autobahn/data/testonly.go | 2 +- .../internal/autobahn/epoch/registry.go | 8 +- .../internal/autobahn/epoch/registry_test.go | 4 +- 14 files changed, 88 insertions(+), 69 deletions(-) diff --git a/sei-db/ledger_db/block/blocksim/block_generator.go b/sei-db/ledger_db/block/blocksim/block_generator.go index 691d20acef..3790250cff 100644 --- a/sei-db/ledger_db/block/blocksim/block_generator.go +++ b/sei-db/ledger_db/block/blocksim/block_generator.go @@ -168,7 +168,7 @@ func (g *BlockGenerator) buildFullCommitQC() (*types.FullCommitQC, []*types.Bloc } } - viewSpec := types.ViewSpec{CommitQC: prev, Epochs: types.EpochDuo{Current: types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0)}} + viewSpec := types.ViewSpec{CommitQC: prev, Epochs: types.NewEpochDuo(types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0), utils.None[*types.Epoch]())} leader := committee.Leader(viewSpec.View()) appQC := func() utils.Option[*types.AppQC] { if n := viewSpec.NextGlobalBlock(); n > 0 { diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 54ea9badbf..6706bb8f8d 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -9,6 +9,7 @@ import ( // EpochDuo is a sliding window of up to two consecutive epochs. // Current is always set; Prev is absent only for epoch 0. +// Construct via NewEpochDuo — a zero EpochDuo (nil Current) is invalid. // // Next is intentionally not held: new-committee lane traffic is admitted only // after CommitQC advances Current into the next epoch. @@ -17,6 +18,14 @@ type EpochDuo struct { Current *Epoch } +// NewEpochDuo builds a Prev|Current window. current must be non-nil. +func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { + if current == nil { + panic("NewEpochDuo: Current must be non-nil") + } + return EpochDuo{Prev: prev, Current: current} +} + // ErrRoadBeforeWindow is returned by EpochForRoad when the road is older than // WindowFirst (behind the duo). var ErrRoadBeforeWindow = errors.New("road before epoch duo window") @@ -55,7 +64,7 @@ func (w EpochDuo) EpochOptForRoad(roadIdx RoadIndex) utils.Option[*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) { + if w.Current.RoadRange().Has(roadIdx) { return utils.Some(w.Current) } return utils.None[*Epoch]() @@ -71,7 +80,7 @@ func (w EpochDuo) WindowFirst() RoadIndex { // EpochForIndex returns Current or Prev by epoch index. func (w EpochDuo) EpochForIndex(idx EpochIndex) (*Epoch, error) { - if w.Current != nil && w.Current.EpochIndex() == idx { + if w.Current.EpochIndex() == idx { return w.Current, nil } if prev, ok := w.Prev.Get(); ok && prev.EpochIndex() == idx { diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 2b91986489..1376eae260 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -21,9 +21,18 @@ func testDuoEpochs(t *testing.T) (prev, current *types.Epoch) { return prev, current } +func TestNewEpochDuo_PanicsOnNilCurrent(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("NewEpochDuo(nil, ...) should panic") + } + }() + _ = types.NewEpochDuo(nil, utils.None[*types.Epoch]()) +} + func TestEpochForRoad_HitsCurrentEpoch(t *testing.T) { _, current := testDuoEpochs(t) - w := types.EpochDuo{Current: current} + w := types.NewEpochDuo(current, utils.None[*types.Epoch]()) ep, err := w.EpochForRoad(150) if err != nil { t.Fatalf("EpochForRoad(150): %v", err) @@ -35,7 +44,7 @@ func TestEpochForRoad_HitsCurrentEpoch(t *testing.T) { func TestEpochForRoad_HitsPrevEpoch(t *testing.T) { prev, current := testDuoEpochs(t) - w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + w := types.NewEpochDuo(current, utils.Some(prev)) ep, err := w.EpochForRoad(50) if err != nil { t.Fatalf("EpochForRoad(50): %v", err) @@ -47,7 +56,7 @@ func TestEpochForRoad_HitsPrevEpoch(t *testing.T) { func TestEpochForRoad_OutsideWindowReturnsError(t *testing.T) { _, current := testDuoEpochs(t) - w := types.EpochDuo{Current: current} + w := types.NewEpochDuo(current, utils.None[*types.Epoch]()) _, err := w.EpochForRoad(999) if !errors.Is(err, types.ErrRoadAfterWindow) { t.Fatalf("EpochForRoad(999) = %v, want ErrRoadAfterWindow", err) @@ -64,7 +73,7 @@ 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, Next: 200}, utils.GenTimestamp(rng), committee, 101) - w := types.EpochDuo{Prev: utils.Some(openEpoch), Current: current} + w := types.NewEpochDuo(current, utils.Some(openEpoch)) ep, err := w.EpochForRoad(150) if err != nil { t.Fatalf("EpochForRoad(150): %v", err) @@ -77,7 +86,7 @@ func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { func TestEpochForRoad_AbsentPrevSkipped(t *testing.T) { _, current := testDuoEpochs(t) - w := types.EpochDuo{Current: current} + w := types.NewEpochDuo(current, utils.None[*types.Epoch]()) _, err := w.EpochForRoad(50) if !errors.Is(err, types.ErrRoadBeforeWindow) { t.Fatalf("EpochForRoad(50) with absent Prev = %v, want ErrRoadBeforeWindow", err) @@ -86,7 +95,7 @@ func TestEpochForRoad_AbsentPrevSkipped(t *testing.T) { func TestEpochForRoad_BeforeAndAfterWithPrev(t *testing.T) { prev, current := testDuoEpochs(t) - w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + w := types.NewEpochDuo(current, utils.Some(prev)) if _, err := w.EpochForRoad(50); err != nil { t.Fatalf("EpochForRoad(50) in prev: %v", err) } @@ -101,7 +110,7 @@ func TestEpochForRoad_BeforeAndAfterWithPrev(t *testing.T) { func TestWindowFirst_WithPrev(t *testing.T) { prev, current := testDuoEpochs(t) - w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + w := types.NewEpochDuo(current, utils.Some(prev)) if got, want := w.WindowFirst(), prev.RoadRange().First; got != want { t.Fatalf("WindowFirst() = %d, want %d", got, want) } @@ -109,7 +118,7 @@ func TestWindowFirst_WithPrev(t *testing.T) { func TestWindowFirst_CurrentOnly(t *testing.T) { _, current := testDuoEpochs(t) - w := types.EpochDuo{Current: current} + w := types.NewEpochDuo(current, utils.None[*types.Epoch]()) if got, want := w.WindowFirst(), current.RoadRange().First; got != want { t.Fatalf("WindowFirst() = %d, want %d", got, want) } @@ -117,7 +126,7 @@ func TestWindowFirst_CurrentOnly(t *testing.T) { func TestEpochOptForRoad(t *testing.T) { prev, current := testDuoEpochs(t) - w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + w := types.NewEpochDuo(current, utils.Some(prev)) if ep, ok := w.EpochOptForRoad(50).Get(); !ok || ep != prev { t.Fatalf("EpochOptForRoad(50) = %v, want prev", ep) } @@ -128,7 +137,7 @@ func TestEpochOptForRoad(t *testing.T) { func TestCurrentForRoad(t *testing.T) { prev, current := testDuoEpochs(t) - w := types.EpochDuo{Prev: utils.Some(prev), Current: current} + w := types.NewEpochDuo(current, utils.Some(prev)) if ep, ok := w.CurrentForRoad(150).Get(); !ok || ep != current { t.Fatalf("CurrentForRoad(150) = %v, want current", ep) } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index f4caea18ff..b25744d1af 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -83,7 +83,7 @@ func TestProposalVerifyRejectsEmptyTipcut(t *testing.T) { rng := utils.TestRng() committee, _ := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} // Direct Proposal.Verify rejects empty tipcuts (no LaneQCs / zero GlobalRange). // Local propose waits via WaitForLaneQCs; verification must refuse empty tipcuts @@ -105,7 +105,7 @@ func TestProposalVerifyFreshWithBlocks(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) // Produce a LaneQC for the proposer's lane. @@ -121,7 +121,7 @@ func TestNewProposalRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testing. rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -141,7 +141,7 @@ func TestProposalBlockTimestampStrictlyMonotone(t *testing.T) { committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) firstBlock := ep.FirstBlock() - vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() @@ -164,7 +164,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), Epochs: EpochDuo{Current: ep}} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposer1 := leaderKey(committee, keys, vs1.View()) secondProposal := utils.OrPanic1(NewProposal( @@ -189,7 +189,7 @@ 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{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} k := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) require.NoError(t, fp.Verify(vs)) @@ -203,7 +203,7 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() lQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -221,8 +221,8 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { utils.None[*AppQC](), )) - vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epochs: EpochDuo{Current: ep}} - vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epochs: EpochDuo{Current: ep}} + vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposer1 := leaderKey(committee, keys, vs1a.View()) fp1a := utils.OrPanic1(NewProposal( @@ -243,13 +243,13 @@ func TestProposalVerifyRejectsViewMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a valid proposal at genesis view (0, 0). - vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} leader0 := leaderKey(committee, keys, vs0.View()) 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) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epochs: EpochDuo{Current: ep}} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} err := fp.Verify(vs1) require.Error(t, err) } @@ -258,7 +258,7 @@ func TestProposalVerifyRejectsForgedSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) // Build two valid proposals with different timestamps. @@ -275,7 +275,7 @@ func TestProposalVerifyRejectsWrongProposer(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} correctLeader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -302,7 +302,7 @@ func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} // no timeoutQC + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} // no timeoutQC proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -328,7 +328,7 @@ func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -360,7 +360,7 @@ func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -392,7 +392,7 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -424,7 +424,7 @@ func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -462,7 +462,7 @@ func TestProposalVerifyRejectsMissingLaneQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -484,7 +484,7 @@ func TestProposalVerifyRejectsLaneQCBlockNumberMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -508,7 +508,7 @@ func TestProposalVerifyRejectsInvalidLaneQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -569,7 +569,7 @@ func makeFullProposal( appQC utils.Option[*AppQC], ) *FullProposal { committee := ep.Committee() - vs := ViewSpec{CommitQC: prev, Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{CommitQC: prev, Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} return utils.OrPanic1(NewProposal( leaderKey(committee, keys, vs.View()), vs, time.Now(), @@ -599,10 +599,10 @@ 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()) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: EpochDuo{Current: ep}} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} 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), Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} 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]()) @@ -615,7 +615,7 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} // no previous commitQC, so app starts at None + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} // no previous commitQC, so app starts at None leader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -645,7 +645,7 @@ func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { 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}} + vs := ViewSpec{Epochs: NewEpochDuo(current, utils.Some(prev))} leader := leaderKey(committee, keys, vs.View()) // Fresh AppQC: globalNumber 0 < FirstBlock 1. @@ -674,7 +674,7 @@ func TestProposalVerifyRejectsAppQCMismatch(t *testing.T) { 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}} + vs := ViewSpec{Epochs: NewEpochDuo(current, utils.Some(prev))} leader := leaderKey(committee, keys, vs.View()) goodAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) @@ -699,7 +699,7 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { // 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}} + vs := ViewSpec{Epochs: NewEpochDuo(current, utils.Some(prev))} leader := leaderKey(committee, keys, vs.View()) makeAppQCWithEpoch := func(epochIdx EpochIndex) *AppQC { @@ -738,7 +738,7 @@ func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { 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}} + vs := ViewSpec{Epochs: NewEpochDuo(current, utils.Some(prev))} leader := leaderKey(committee, keys, vs.View()) appHash := GenAppHash(rng) @@ -765,7 +765,7 @@ func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -794,7 +794,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{Epochs: EpochDuo{Current: ep}} + vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} leader0 := leaderKey(committee, keys, vs0.View()) lane := committee.Leader(vs0.View()) laneQC0 := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -815,7 +815,7 @@ func TestProposalVerifyValidReproposal(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuo{Current: ep}} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} require.Equal(t, View{Index: 0, Number: 1, EpochIndex: ep.EpochIndex()}, vs1.View()) leader1 := leaderKey(committee, keys, vs1.View()) @@ -832,7 +832,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -848,7 +848,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuo{Current: ep}} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} leader1 := leaderKey(committee, keys, vs1.View()) // Create a valid reproposal, then tamper it with unnecessary laneQCs. @@ -871,7 +871,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -887,7 +887,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuo{Current: ep}} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} leader1 := leaderKey(committee, keys, vs1.View()) // Build the valid reproposal, then tamper its timestamp to get a different hash. @@ -923,7 +923,7 @@ func TestProposalVerifyRejectsInvalidTimeoutQCSignature(t *testing.T) { } badTimeoutQC := NewTimeoutQC(timeoutVotes) - vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epochs: EpochDuo{Current: ep}} + vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} leader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -939,7 +939,7 @@ func TestViewSpecViewStampsEpochIndex(t *testing.T) { ep := NewEpoch(epochIdx, OpenRoadRange(), time.Time{}, committee, 0) // Without TimeoutQC: epoch index must come from vs.Epoch(). - vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} + vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} if got := vs0.View().EpochIndex; got != epochIdx { t.Fatalf("no-TimeoutQC path: EpochIndex = %d, want %d", got, epochIdx) } @@ -948,7 +948,7 @@ func TestViewSpecViewStampsEpochIndex(t *testing.T) { tqc := NewTimeoutQC([]*FullTimeoutVote{ NewFullTimeoutVote(keys[0], View{EpochIndex: 0}, utils.None[*PrepareQC]()), }) - vs1 := ViewSpec{TimeoutQC: utils.Some(tqc), Epochs: EpochDuo{Current: ep}} + vs1 := ViewSpec{TimeoutQC: utils.Some(tqc), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} 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 d31972cd11..63b0d856e7 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -23,7 +23,7 @@ func BuildCommitQC( laneQCs map[LaneID]*LaneQC, appQC utils.Option[*AppQC], ) *CommitQC { - vs := ViewSpec{CommitQC: prev, Epochs: EpochDuo{Current: epoch}} + vs := ViewSpec{CommitQC: prev, Epochs: NewEpochDuo(epoch, utils.None[*Epoch]())} if len(laneQCs) == 0 { laneQCs = oneBlockLaneQCMap(vs, keys) } diff --git a/sei-tendermint/autobahn/types/wireguard_test.go b/sei-tendermint/autobahn/types/wireguard_test.go index b45ab8df0a..f59e0ae9b8 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{Epochs: EpochDuo{Current: NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 0)}}, + ViewSpec{Epochs: NewEpochDuo(NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 0), utils.None[*Epoch]())}, time.Unix(1, 2), laneQCs, utils.None[*AppQC](), diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index e0b5e1e874..b4cae3f97e 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -878,7 +878,7 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { tipFirst := wantAppFirst + 1000 c := ep0.Committee() 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} + tipDuo := types.NewEpochDuo(tipCurrent, utils.Some(ep0)) ap := types.NewAppProposal(wantAppFirst, qcs[1].Index(), types.GenAppHash(rng), 0) loaded := &loadedAvailState{ diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 683befeedc..423ff674ba 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -378,7 +378,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, Epochs: types.EpochDuo{Current: types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, initialBlock)}} + vs := types.ViewSpec{CommitQC: prev, Epochs: types.NewEpochDuo(types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, initialBlock), utils.None[*types.Epoch]())} fullProposal := utils.OrPanic1(types.NewProposal( leaderKey(committee, keys, vs.View()), vs, @@ -485,7 +485,7 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { } ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, ep0.FirstTimestamp(), utils.OrPanic1(types.NewCommittee(weights)), ep0.FirstBlock()) - duo1 := types.EpochDuo{Prev: utils.Some(ep0), Current: ep1} + duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) errCh := make(chan error, 1) go func() { errCh <- state.PushVote(ctx, vote) }() @@ -526,7 +526,7 @@ func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { weights := map[types.PublicKey]uint64{keys[0].Public(): 1000, keys[1].Public(): 1000} ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, ep0.FirstTimestamp(), utils.OrPanic1(types.NewCommittee(weights)), ep0.FirstBlock()) - duo1 := types.EpochDuo{Prev: utils.Some(ep0), Current: ep1} + duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) errCh := make(chan error, 1) go func() { errCh <- state.PushVote(ctx, vote) }() diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index fe4560561e..2f591a0bdb 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -142,14 +142,15 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - duo := types.EpochDuo{Current: viewEpoch} + prevOpt := utils.None[*types.Epoch]() if viewEpoch.EpochIndex() > 0 { prev, err := registry.EpochAt(epoch.FirstRoad(viewEpoch.EpochIndex() - 1)) if err != nil { return inner{}, fmt.Errorf("EpochAt(prev): %w", err) } - duo.Prev = utils.Some(prev) + prevOpt = utils.Some(prev) } + duo := types.NewEpochDuo(viewEpoch, prevOpt) return inner{persistedInner: persisted, epochs: duo}, nil } diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index e969dd7e44..15fd0117e5 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, Epochs: types.EpochDuo{Current: viewEp}} + vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epochs: types.NewEpochDuo(viewEp, utils.None[*types.Epoch]())} currentView := vs.View() committee := viewEp.Committee() diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index c2452bbb29..9529b1a0f1 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -221,7 +221,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { malBlocks = append(malBlocks, b) } } - viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epochs: types.EpochDuo{Current: registry.LatestEpoch()}} + viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epochs: types.NewEpochDuo(registry.LatestEpoch(), utils.None[*types.Epoch]())} 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 b6a14ad601..7026668be9 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, Epochs: types.EpochDuo{Current: ep}} + vs := types.ViewSpec{CommitQC: prev, Epochs: types.NewEpochDuo(ep, utils.None[*types.Epoch]())} p := types.NewAppProposal(cqc.GlobalRange().Next-1, vs.View().Index, types.GenAppHash(rng), ep.EpochIndex()) appQC = utils.Some(TestAppQC(keys, p)) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 630a9a6010..5fe051916f 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -295,13 +295,13 @@ func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { if err != nil { return types.EpochDuo{}, fmt.Errorf("epoch %d (road %d) not in registry", centerIdx, roadIndex) } - duo := types.EpochDuo{Current: current} + prev := utils.None[*types.Epoch]() if centerIdx > 0 { - if prev, err := r.EpochAt(FirstRoad(centerIdx - 1)); err == nil { - duo.Prev = utils.Some(prev) + if p, err := r.EpochAt(FirstRoad(centerIdx - 1)); err == nil { + prev = utils.Some(p) } } - return duo, nil + return types.NewEpochDuo(current, prev), nil } // WaitForDuo blocks until DuoAt(roadIndex) can succeed (Current registered), diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 7409e978c5..c92b0dd221 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -264,7 +264,7 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { if duo.Prev.IsPresent() { t.Fatalf("DuoAt(0).Prev = %v, want absent for epoch 0", duo.Prev) } - if duo.Current == nil || duo.Current.EpochIndex() != 0 { + if duo.Current.EpochIndex() != 0 { t.Fatalf("DuoAt(0).Current.EpochIndex() wrong, want 0") } } @@ -281,7 +281,7 @@ func TestDuoAt_MiddleEpoch(t *testing.T) { if !ok || prev.EpochIndex() != 1 { t.Fatalf("DuoAt(epoch 2).Prev.EpochIndex() wrong, want 1") } - if duo.Current == nil || duo.Current.EpochIndex() != 2 { + if duo.Current.EpochIndex() != 2 { t.Fatalf("DuoAt(epoch 2).Current.EpochIndex() wrong, want 2") } } From 24057aeb345da5348cbdd782d178cb1060c49fbb Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 18:21:07 -0700 Subject: [PATCH 11/85] fix(autobahn): enforce contiguous Prev|Current in NewEpochDuo (CON-358) Require abutting road ranges and consecutive indices when Prev is set, and cap EpochForRoad at Current.Next so open Prev cannot extend the window. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 32 +++++++++--- .../autobahn/types/epoch_duo_test.go | 49 +++++++++++++------ .../autobahn/types/proposal_test.go | 49 +++++++++++-------- 3 files changed, 89 insertions(+), 41 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 6706bb8f8d..c85439b6fe 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -19,10 +19,24 @@ type EpochDuo struct { } // NewEpochDuo builds a Prev|Current window. current must be non-nil. +// When prev is present it must be the immediately preceding epoch: index +// Current-1 and road ranges abut (Prev.Next == Current.First). func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { if current == nil { panic("NewEpochDuo: Current must be non-nil") } + if p, ok := prev.Get(); ok { + if p == nil { + panic("NewEpochDuo: Prev must be non-nil when present") + } + if want := current.EpochIndex(); p.EpochIndex()+1 != want { + panic(fmt.Sprintf("NewEpochDuo: Prev epoch %d not contiguous with Current %d", + p.EpochIndex(), want)) + } + if got, want := p.RoadRange().Next, current.RoadRange().First; got != want { + panic(fmt.Sprintf("NewEpochDuo: Prev roads end at %d, Current starts at %d", got, want)) + } + } return EpochDuo{Prev: prev, Current: current} } @@ -39,13 +53,19 @@ func (w EpochDuo) all() [2]utils.Option[*Epoch] { } // 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. +// The window ends at Current.Next — roads at or past that bound are +// ErrRoadAfterWindow even if Prev uses an open range. Within the window, +// Current is checked before Prev so an open-range Prev cannot mask Current. +// Returns ErrRoadBeforeWindow when older than WindowFirst. 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 - } + if roadIdx >= w.Current.RoadRange().Next { + return nil, fmt.Errorf("road %d after window %v: %w", roadIdx, w, ErrRoadAfterWindow) + } + if w.Current.RoadRange().Has(roadIdx) { + return w.Current, nil + } + if prev, ok := w.Prev.Get(); ok && prev.RoadRange().Has(roadIdx) { + return prev, nil } if roadIdx < w.WindowFirst() { return nil, fmt.Errorf("road %d before window %v: %w", roadIdx, w, ErrRoadBeforeWindow) diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 1376eae260..2c7ea686e5 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -30,6 +30,35 @@ func TestNewEpochDuo_PanicsOnNilCurrent(t *testing.T) { _ = types.NewEpochDuo(nil, utils.None[*types.Epoch]()) } +func TestNewEpochDuo_PanicsOnNonContiguousIndex(t *testing.T) { + prev, _ := testDuoEpochs(t) + rng := utils.TestRng() + weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} + committee := utils.OrPanic1(types.NewCommittee(weights)) + // Roads abut, but index jumps 0 → 2. + current := types.NewEpoch(2, types.RoadRange{First: 100, Next: 200}, utils.GenTimestamp(rng), committee, 101) + defer func() { + if recover() == nil { + t.Fatal("NewEpochDuo with non-contiguous indices should panic") + } + }() + _ = types.NewEpochDuo(current, utils.Some(prev)) +} + +func TestNewEpochDuo_PanicsOnNonContiguousRoads(t *testing.T) { + rng := utils.TestRng() + weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} + committee := utils.OrPanic1(types.NewCommittee(weights)) + prev := 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) + defer func() { + if recover() == nil { + t.Fatal("NewEpochDuo with non-abutting roads should panic") + } + }() + _ = types.NewEpochDuo(current, utils.Some(prev)) +} + func TestEpochForRoad_HitsCurrentEpoch(t *testing.T) { _, current := testDuoEpochs(t) w := types.NewEpochDuo(current, utils.None[*types.Epoch]()) @@ -67,20 +96,12 @@ func TestEpochForRoad_OutsideWindowReturnsError(t *testing.T) { } } -func TestEpochForRoad_OpenRangePrevDoesNotMaskCurrent(t *testing.T) { - rng := utils.TestRng() - 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) - w := types.NewEpochDuo(current, utils.Some(openEpoch)) - 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_PastCurrentNotMaskedByPrev(t *testing.T) { + prev, current := testDuoEpochs(t) + w := types.NewEpochDuo(current, utils.Some(prev)) + _, err := w.EpochForRoad(200) + if !errors.Is(err, types.ErrRoadAfterWindow) { + t.Fatalf("EpochForRoad(200) = %v, want ErrRoadAfterWindow", err) } } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index b25744d1af..6e646e8c52 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -21,6 +21,16 @@ func genFreshEpoch(rng utils.Rng, committee *Committee) *Epoch { ) } +// viewSpecContiguousDuo builds Prev|Current with abutting roads [0,1)|[1,Max) and a +// CommitQC so View.Index is inside Current (for AppQC Prev|Current verify tests). +func viewSpecContiguousDuo(keys []SecretKey, committee *Committee) ViewSpec { + const split RoadIndex = 1 + prev := NewEpoch(0, RoadRange{First: 0, Next: split}, time.Time{}, committee, 1) + current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 1) + qc := BuildCommitQC(prev, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + return ViewSpec{CommitQC: utils.Some(qc), Epochs: NewEpochDuo(current, utils.Some(prev))} +} + // leaderKey returns the secret key for the leader of the given view. func leaderKey(committee *Committee, keys []SecretKey, view View) SecretKey { leader := committee.Leader(view) @@ -643,13 +653,12 @@ func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { 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: NewEpochDuo(current, utils.Some(prev))} + vs := viewSpecContiguousDuo(keys, committee) leader := leaderKey(committee, keys, vs.View()) - // Fresh AppQC: globalNumber 0 < FirstBlock 1. - goodAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + // Fresh AppQC: globalNumber 0 < FirstBlock 1. Road must sit in the AppQC epoch. + road := RoadIndex(tc.appEpoch) // epoch 0 → road 0; epoch 1 → road 1 (split) + goodAppQC := makeAppQCFor(keys, 0, road, GenAppHash(rng), tc.appEpoch) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) tamperedFP := &FullProposal{ @@ -672,15 +681,14 @@ func TestProposalVerifyRejectsAppQCMismatch(t *testing.T) { 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: NewEpochDuo(current, utils.Some(prev))} + vs := viewSpecContiguousDuo(keys, committee) leader := leaderKey(committee, keys, vs.View()) - goodAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + road := RoadIndex(tc.appEpoch) + goodAppQC := makeAppQCFor(keys, 0, road, GenAppHash(rng), tc.appEpoch) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) - differentAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + differentAppQC := makeAppQCFor(keys, 0, road, GenAppHash(rng), tc.appEpoch) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: fp.laneQCs, @@ -695,15 +703,15 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - // 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: NewEpochDuo(current, utils.Some(prev))} + vs := viewSpecContiguousDuo(keys, committee) leader := leaderKey(committee, keys, vs.View()) makeAppQCWithEpoch := func(epochIdx EpochIndex) *AppQC { - p := NewAppProposal(0, 0, GenAppHash(rng), epochIdx) + road := RoadIndex(0) + if epochIdx >= 1 { + road = 1 + } + p := NewAppProposal(0, road, GenAppHash(rng), epochIdx) v := NewAppVote(p) var votes []*Signed[*AppVote] for _, k := range keys { @@ -736,13 +744,12 @@ func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { 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: NewEpochDuo(current, utils.Some(prev))} + vs := viewSpecContiguousDuo(keys, committee) leader := leaderKey(committee, keys, vs.View()) appHash := GenAppHash(rng) - goodAppQC := makeAppQCFor(keys, 0, 0, appHash, tc.appEpoch) + road := RoadIndex(tc.appEpoch) + goodAppQC := makeAppQCFor(keys, 0, road, appHash, tc.appEpoch) 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). @@ -750,7 +757,7 @@ func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { for i := range otherKeys { otherKeys[i] = GenSecretKey(rng) } - badAppQC := makeAppQCFor(otherKeys, 0, 0, appHash, tc.appEpoch) + badAppQC := makeAppQCFor(otherKeys, 0, road, appHash, tc.appEpoch) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: fp.laneQCs, From cab435ef8d7e847c0a82262d2150bcde714ce3a6 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 19:08:22 -0700 Subject: [PATCH 12/85] docs(autobahn): trim EpochDuo comments to contracts and invariants (CON-358) Document Prev|Current contiguity and tip-interlock requirements; drop implementation narration from registry and admit-path godocs. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 44 ++--- .../internal/autobahn/avail/block_votes.go | 5 +- .../internal/autobahn/avail/inner.go | 17 +- .../internal/autobahn/avail/state.go | 70 +++----- .../internal/autobahn/consensus/inner.go | 12 +- .../internal/autobahn/data/state.go | 58 ++----- .../internal/autobahn/epoch/registry.go | 160 +++++------------- 7 files changed, 108 insertions(+), 258 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index c85439b6fe..1f6bfa37f4 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -7,20 +7,24 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// EpochDuo is a sliding window of up to two consecutive epochs. -// Current is always set; Prev is absent only for epoch 0. -// Construct via NewEpochDuo — a zero EpochDuo (nil Current) is invalid. +// EpochDuo is a sliding Prev|Current epoch window. // -// Next is intentionally not held: new-committee lane traffic is admitted only -// after CommitQC advances Current into the next epoch. +// Invariants: +// - Current is always set (construct via NewEpochDuo). +// - Prev is absent iff Current is epoch 0. +// - When Prev is present, Prev and Current are contiguous: consecutive +// epoch indices and Prev.RoadRange().Next == Current.RoadRange().First. +// +// New-committee lane traffic is admitted only after Current advances. type EpochDuo struct { - Prev utils.Option[*Epoch] // absent if Current is epoch 0 + Prev utils.Option[*Epoch] // absent iff Current is epoch 0; else contiguous with Current Current *Epoch } -// NewEpochDuo builds a Prev|Current window. current must be non-nil. -// When prev is present it must be the immediately preceding epoch: index -// Current-1 and road ranges abut (Prev.Next == Current.First). +// NewEpochDuo builds a Prev|Current window. +// +// Requirements: current non-nil; when prev is present, non-nil and contiguous +// with current (index Current-1, Prev.Next == Current.First). Panics otherwise. func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { if current == nil { panic("NewEpochDuo: Current must be non-nil") @@ -40,23 +44,19 @@ func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { return EpochDuo{Prev: prev, Current: current} } -// ErrRoadBeforeWindow is returned by EpochForRoad when the road is older than -// WindowFirst (behind the duo). +// ErrRoadBeforeWindow is returned when roadIdx is older than WindowFirst. var ErrRoadBeforeWindow = errors.New("road before epoch duo window") -// ErrRoadAfterWindow is returned by EpochForRoad when the road is newer than -// Current (at or past Current.Next). +// ErrRoadAfterWindow is returned when roadIdx is 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} } -// EpochForRoad returns the epoch whose road range contains roadIdx. -// The window ends at Current.Next — roads at or past that bound are -// ErrRoadAfterWindow even if Prev uses an open range. Within the window, -// Current is checked before Prev so an open-range Prev cannot mask Current. -// Returns ErrRoadBeforeWindow when older than WindowFirst. +// EpochForRoad returns the epoch containing roadIdx. +// Window is [WindowFirst, Current.Next). Outside → ErrRoadBeforeWindow / +// ErrRoadAfterWindow. Current is preferred when both ranges could match. func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { if roadIdx >= w.Current.RoadRange().Next { return nil, fmt.Errorf("road %d after window %v: %w", roadIdx, w, ErrRoadAfterWindow) @@ -81,8 +81,8 @@ func (w EpochDuo) EpochOptForRoad(roadIdx RoadIndex) utils.Option[*Epoch] { return utils.None[*Epoch]() } -// CurrentForRoad returns Current when roadIdx is in Current's range; else None. -// Unlike EpochOptForRoad, Prev is never admitted. +// CurrentForRoad returns Current if roadIdx is in Current's range; else None. +// Prev is never admitted. func (w EpochDuo) CurrentForRoad(roadIdx RoadIndex) utils.Option[*Epoch] { if w.Current.RoadRange().Has(roadIdx) { return utils.Some(w.Current) @@ -90,7 +90,7 @@ func (w EpochDuo) CurrentForRoad(roadIdx RoadIndex) utils.Option[*Epoch] { return utils.None[*Epoch]() } -// WindowFirst is the earliest road still in Prev|Current. +// WindowFirst is the earliest road in Prev|Current. func (w EpochDuo) WindowFirst() RoadIndex { if prev, ok := w.Prev.Get(); ok { return prev.RoadRange().First @@ -109,7 +109,7 @@ func (w EpochDuo) EpochForIndex(idx EpochIndex) (*Epoch, error) { return nil, fmt.Errorf("epoch %d not in window %v", idx, w) } -// String returns a compact description of the epoch indices in the window. +// String returns the epoch indices in the window. func (w EpochDuo) String() string { s := "epochs [" sep := "" diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 2b76bb15a2..e68e750d2f 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -5,8 +5,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// laneVoteSet caches Current-epoch weight for one hash. -// header survives reweight; qc is set at quorum. +// laneVoteSet holds weight toward LaneQC for one header hash under Current. type laneVoteSet struct { weight uint64 votes []*types.Signed[*types.LaneVote] @@ -33,7 +32,7 @@ func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneV return utils.Some(s.qc) } -// blockVotes weights Current only; reweight on epoch advance. +// blockVotes weights votes under Current; reweight on epoch advance. type blockVotes struct { byKey map[types.PublicKey]*types.Signed[*types.LaneVote] byHash map[types.BlockHeaderHash]*laneVoteSet diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index dcc58d040f..5344bb5896 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -130,10 +130,7 @@ func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded uti return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } - // Restore persisted CommitQCs. Tipcut insert above may already hold the - // 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. + // Restore CommitQCs above commitQCs.next. Epoch must already be seeded. for _, lqc := range l.commitQCs { if lqc.Index < i.commitQCs.next { continue @@ -150,10 +147,8 @@ 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 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. + // Restore blocks; create queues for any WAL lane (including outside Current). + // Old lanes are retained until lane-expiry (TODO). for lane, bs := range l.blocks { if len(bs) == 0 { continue @@ -201,10 +196,8 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ return i.lanes[lane].votes.q[n].laneQC() } -// 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. +// advanceEpoch installs nextDuo at a boundary. Adds Current lanes; does not +// delete old lanes (TODO(lane-expiry)). func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { current := nextDuo.Current for lane := range current.Committee().Lanes().All() { diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 6a29f3e6e3..3e79b54c69 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -158,11 +158,8 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } - // Operating duo is the CommitQC tipcut (not the prune-anchor road). - // 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. + // DuoAt(CommitQC tipcut). Seeding is data.SetupInitialDuo; missing epoch hard-fails. + // Tip order: consensus.NewState (avail≥consensus), p2p.checkRestartTips (consensus≥data). commitTip := types.RoadIndex(0) if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() @@ -256,9 +253,7 @@ func (s *State) waitRoadInWindow( 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). +// waitEpochForRoad: too early waits; behind window → None (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) }, @@ -266,10 +261,8 @@ func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) ( ) } -// 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. +// waitCurrentForRoad blocks until roadIdx is in Current (too early); +// None if behind Current (stale). Prev is not admitted — CommitQCs are Current-only. func (s *State) waitCurrentForRoad(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.CurrentForRoad(roadIdx) }, @@ -299,10 +292,8 @@ func (s *State) admitRoadOrDrop( return ep, nil } -// waitPruneLeash blocks until latest AppQC is from epochIdx or later (prune -// leash: N-1 must be fully pruned before sealing N drops Prev). incoming, if -// present, is the AppQC on this PushAppQC call; it counts before prune updates -// latestAppQC. +// waitPruneLeash blocks until latest AppQC is from epochIdx or later. +// incoming, if present, counts before latestAppQC is updated. func (s *State) waitPruneLeash(ctx context.Context, epochIdx types.EpochIndex, incoming utils.Option[*types.AppQC]) error { for inner, ctrl := range s.inner.Lock() { ready := func() bool { @@ -331,13 +322,9 @@ func (s *State) waitPruneLeash(ctx context.Context, epochIdx types.EpochIndex, i panic("unreachable") } -// waitCommitEpochLeashes enforces tip-interlock gates before inserting a -// 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 prune leash. Epoch 0 has no prior leash. -// See epoch.Registry tip-interlock docs. +// waitCommitEpochLeashes enforces tip-interlock before sealing epoch N>0 +// (closingEpoch = last road of N). Mid-N admits are not gated. Epoch 0: no-op. +// incoming: AppQC on PushAppQC (None for PushCommitQC). See Registry invariants. func (s *State) waitCommitEpochLeashes( ctx context.Context, epochIdx types.EpochIndex, @@ -398,10 +385,8 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi panic("unreachable") } -// 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 tip-interlock leashes (see -// waitCommitEpochLeashes / Registry tip-interlock docs). +// PushCommitQC admits qc for Current only (too early waits; stale drops), +// then tip-interlock leashes when sealing (waitCommitEpochLeashes). func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() if idx > 0 { @@ -409,9 +394,6 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - // 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. ep, err := s.admitRoadOrDrop(ctx, idx, "CommitQC", s.waitCurrentForRoad) if err != nil || ep == nil { return err @@ -419,8 +401,6 @@ 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(). - // Wait — do not drop (caller may not retry). closing := idx+1 == ep.RoadRange().Next if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex(), closing, utils.None[*types.AppQC]()); err != nil { return err @@ -500,9 +480,8 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] return nil } -// PushAppQC pushes an AppQC to the state. It requires a corresponding CommitQC -// as a justification. Tipcut pushBack of that CommitQC uses the same epoch -// leashes as PushCommitQC (waitCommitEpochLeashes). +// PushAppQC requires a justifying CommitQC; tipcut insert uses the same +// sealing leashes as PushCommitQC. 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() { @@ -658,11 +637,10 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos return nil } -// PushVote pushes a LaneVote to the state. -// Waits until the lane has enough capacity for the new vote. -// It does NOT wait for the previous votes. +// PushVote verifies off-lock against Current, then under lock credits with the +// live duo (drop if Current advanced and signer left). Does not wait for prior votes. func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { - // Verify off-lock against a duo snapshot (crypto off the hot lock). + // Verify off-lock against a duo snapshot. duo := s.epochDuo.Load() c := duo.Current.Committee() if err := vote.Msg().Verify(c); err != nil { @@ -684,10 +662,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote }); err != nil { return err } - // WaitUntil may release the lock; a boundary advanceEpoch can reweight - // vote sets under a new Current. Count only with the live duo — if - // Current advanced, drop signers that left the committee (sig already - // checked; no re-VerifySig). + // WaitUntil may release the lock; re-check membership under live Current. live := inner.epochDuo.Load() if live.Current.EpochIndex() != verifiedEpoch && !live.Current.Committee().HasReplica(vote.Key()) { @@ -742,13 +717,8 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc return headers, nil } -// fullCommitQC returns the FullCommitQC for road n and the signing epoch. -// Returns types.ErrPruned when the CommitQC/headers were pruned, or when the -// road is behind the epoch duo (ErrRoadBeforeWindow) so the export loop can -// 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). +// fullCommitQC returns the FullCommitQC for road n and its signing epoch. +// ErrRoadBeforeWindow → ErrPruned (export may jump ahead). ErrRoadAfterWindow hard-fails. 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/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 2f591a0bdb..610d61e15a 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -120,10 +120,8 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( persisted = *decoded } - // View epoch = tipcut road; CommitQC may still be the prior epoch's last road. - // 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. + // View epoch = tipcut; CommitQC may be prior epoch. Seeding is data's; + // missing epoch hard-fails. Tip order: NewState / checkRestartTips. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) viewEpoch, err := registry.EpochAt(nextViewRoad) if err != nil { @@ -158,8 +156,7 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { 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. + // Re-verify. Epoch must be seeded; missing → hard error (no WaitForDuo). ep, err := s.registry.EpochAt(qc.Proposal().Index()) if err != nil { return fmt.Errorf("EpochAt(%d): %w", qc.Proposal().Index(), err) @@ -175,8 +172,7 @@ func (s *State) pushCommitQC(qc *types.CommitQC) error { nextRoad := qc.Proposal().Index() + 1 nextDuo := i.epochs if !i.epochs.Current.RoadRange().Has(nextRoad) { - // Boundary or coalesced tip past Current: open duo at tipcut. - // Invariant: seeded before this tip (SetupInitialDuo / AdvanceIfNeeded). + // Tipcut past Current: DuoAt must already be seeded. duo, err := s.registry.DuoAt(nextRoad) if err != nil { logger.Error("tipcut duo not in registry after avail CommitQC tip", diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index f6d688f14a..b4577841dd 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -195,13 +195,8 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { inner: utils.NewWatch(newInner(cfg.Registry.FirstBlock())), blockDB: blockDB, } - // Seed epochs before replay: data/ is the restart anchor, so SetupInitialDuo - // registers placeholder epochs for the whole retained CommitQC span (see - // epoch.Registry tip-interlock). EpochAt then resolves committees during - // replay; the live operating window (epochDuo) is centered on the tip below. - // - // TODO(autobahn): LastExecutedBlock comes from app.LastBlockHeight() (app - // state DB). Move execution tip into Giga storage; stop reading the app DB. + // Seed epochs before replay (data owns SetupInitialDuo). + // TODO(autobahn): persist execution tip in Giga storage (stop using app DB). commitQCs, err := commitQCSpan(blockDB) if err != nil { return nil, fmt.Errorf("scan CommitQC span: %w", err) @@ -216,9 +211,7 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { return nil, fmt.Errorf("loadFromBlockDB: %w", err) } - // Center the live operating window on the CommitQC tip (tipcut = Index+1), - // matching live PushQC after a boundary store. SetupInitialDuo already - // EnsureDuoAt(Next) when a span is present, so DuoAt succeeds. + // Center epochDuo on CommitQC tipcut (Index+1). for in := range s.inner.Lock() { initRoad := types.RoadIndex(0) if in.nextQC > in.first { @@ -272,15 +265,9 @@ func boundaryQCRoad(blockDB types.BlockDB, reverse bool) (types.RoadIndex, bool, return qc.QC().Proposal().Index(), true, nil } -// nextRoadToExecute returns a half-open execution tipcut: the next RoadIndex -// that still needs execution. Covering CommitQC road R for LastExecutedBlock: -// mid-range → R; fully finished (IsLastBlock) → R+1. Same tipcut shape as -// CommitQC Index+1; registry EnsureExecTipcut maps tipcut → last completed -// road. -// -// LastExecutedBlock 0 means fresh/unknown (Config default; giga only sets it -// when app.LastBlockHeight() > 0) → None. Missing/pruned covering QC for a -// positive height is an error (inconsistent app tip vs BlockDB). +// nextRoadToExecute is the half-open execution tipcut. Covering QC road R: +// mid-range → R; IsLastBlock → R+1. LastExecutedBlock 0 → None. Positive height +// with missing/pruned covering QC → error. func (s *State) nextRoadToExecute(blockDB types.BlockDB) (utils.Option[types.RoadIndex], error) { n := s.cfg.LastExecutedBlock if n == 0 { @@ -323,9 +310,8 @@ func (s *State) nextRoadToExecute(blockDB types.BlockDB) (utils.Option[types.Roa // blocks are allowed only at the tail. BlockDB enforces other consistency; this // method only rejects a first QC before committee genesis. // -// Epochs are seeded (SetupInitialDuo) by the caller before replay, so EpochAt -// resolves each retained QC's committee here. This is recovery, not live -// admission — PushQC/PushBlock use the epochDuo window, never EpochAt. +// Epochs must already be seeded (SetupInitialDuo) so EpochAt resolves QC committees. +// Live PushQC/PushBlock use epochDuo, not EpochAt. func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { for in := range s.inner.Lock() { err := func() error { @@ -474,16 +460,8 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma return nil } -// 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. +// PushQC atomically admits qc and optional finalized blocks. +// EpochForRoad via epochDuo only (not Registry). Before-window hard-fails. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { ep, err := s.epochDuo.Load().EpochForRoad(qc.QC().Proposal().Index()) if err != nil { @@ -519,8 +497,7 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("b.Verify(): %w", err) } } - // Boundary: resolve next duo off-lock before mutating nextQC - // (WaitForDuo), so a failed wait cannot strand the tip. + // Closing Current: WaitForDuo(tipcut) before mutating nextQC. idx := qc.QC().Proposal().Index() var nextDuo *types.EpochDuo duo := s.epochDuo.Load() @@ -531,11 +508,9 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty } 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 nextDuo - // (stale concurrent PushQC must not regress the window). + // Only the first inserter may advance epochDuo. applied := inner.nextQC == gr.First for inner.nextQC < gr.Next { inner.qcs[inner.nextQC] = qc @@ -577,13 +552,8 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC return s.qcFromDB(n) } -// PushBlock pushes block to the state. -// The QC for n must already be present (guaranteed by PushQC ordering), unless -// the height is already in the contiguous block prefix (n < nextBlock) — in -// that case the block is dropped silently (already stored or executed/evicted). -// -// Same epochDuo admission as PushQC: before-window hard-fails. Do not fall back -// to Registry — see PushQC. +// PushBlock requires a covering QC (or n < nextBlock → silent drop). +// Same epochDuo before-window hard-fail as 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 5fe051916f..df5712f338 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -36,81 +36,30 @@ type registryState struct { latest types.EpochIndex } -// Registry is the authoritative source of epoch and committee information. -// All layers (consensus, data, avail) read from it. +// Registry is the authoritative store of epoch/committee metadata for all +// layers (consensus, data, avail). // -// 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. +// Invariants: +// - Independent of each layer's live EpochDuo (Prev|Current). Duo admits +// traffic; the registry may retain more epochs for restart and leashes. +// - Execution cannot pass commit. Sealing epoch N (N>0) requires registry +// N+1 (execution leash) and AppQC covering N-1 before Prev is dropped +// (prune leash). Finishing LastRoad(N-1) seeds epoch N+1 (AdvanceIfNeeded). +// - data/ is the sole restart seeder (SetupInitialDuo). Avail/consensus must +// not seed; tip into an unseeded epoch → EpochAt/DuoAt hard-fail. +// - Post-construction tipcuts: avail ≥ consensus ≥ data.CommitTipCut(); +// behind → hard-fail. +// - Placeholders use the genesis committee until real committees are wired. // -// # Tip interlocking (commit ↔ execution) -// -// Commit and execution are independent pipelines but must stay interlocked: -// -// - 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 -// E-1 until E-1 is fully pruned — otherwise FullCommitQC export would skip -// still-queued roads (data/ cannot gap). -// -// 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 — 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: -// -// - data/ is the sole restart seeder (SetupInitialDuo from data.NewState). -// NewRegistry only installs epoch 0; empty BlockDB seeds genesis neighbor -// {0,1} via EnsureDuoAt(FirstRoad(1)) so sealing epoch 0 can WaitForDuo. -// Avail/consensus tips may lead data (async FullCommitQC→ -// data.PushQC), but do not seed the registry. Lead into an unseeded epoch → -// EpochAt/DuoAt hard-fail (no soft-heal). When the execution tipcut is already -// in the CommitQC tip's epoch N, EnsureExecTipcut seeds N+1 (N-1 is done) — that -// is how a tipcut in N+1 stays inside the reconstructed window. Seeding N+2 still -// requires the execution tipcut to be past LastRoad(N) (fully finished that -// road → tipcut LastRoad(N)+1); AdvanceIfNeeded owns the LastRoad check. -// Leading by more than one epoch should not happen: sealing N+1 needs registry N+2, which -// needs execution of LastRoad(N), which needs that CommitQC in data — so if -// data tip is still in N, avail/consensus cannot have entered N+2. If it -// did, EpochAt/DuoAt hard-fails (corrupt / inconsistent local state; do -// 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. -// - SetupInitialDuo may register genesis-committee placeholders (temporary; -// real committees TBD) — that is not inventing roads or repairing -// inconsistent tips. data.NewState errors if a positive LastExecutedBlock -// has no covering CommitQC. Empty CommitQC ranges are rejected. -// - 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). +// TODO(autobahn): replace genesis placeholders with epoch info on blocks. type Registry struct { state utils.RWMutex[*registryState] - // highestEpoch is a monotonic high-water mark for WaitForDuo. - // Kept off registryState so EpochAt can stay on the RLock fast path. + // highestEpoch wakes WaitForDuo; monotonic, off registryState for EpochAt RLock. highestEpoch utils.AtomicSend[types.EpochIndex] } -// NewRegistry creates a Registry with the genesis committee (epoch 0 only). -// Epoch 1+ placeholders are seeded by data.NewState via SetupInitialDuo. +// NewRegistry creates a Registry with genesis epoch 0 only. +// Epoch 1+ are seeded by data.NewState via SetupInitialDuo. func NewRegistry( committee *types.Committee, firstBlock types.GlobalBlockNumber, @@ -126,24 +75,18 @@ func NewRegistry( }, nil } -// SetupInitialDuo seeds placeholder epochs on restart. Called only from -// data.NewState (see Registry tip-interlock docs). Avail/consensus do not seed. -// -// 1. commitQCs — half-open retained CommitQC road range [First, Next). Seeds -// every epoch covering [First, Next), then EnsureDuoAt(Next). None = empty -// store: seed genesis neighbor {0,1} via EnsureDuoAt(FirstRoad(1)) so sealing -// epoch 0 can WaitForDuo(FirstRoad(1)). Empty range (First >= Next) panics. -// 2. nextRoadToExecute — half-open execution tipcut; EnsureExecTipcut -// restores AdvanceIfNeeded lookahead. None = nothing fully executed yet. -// Ignored when past commit tipcut (Next). Present without a commit span -// panics (inconsistent: execution cannot lead an empty CommitQC store). +// SetupInitialDuo seeds placeholder epochs on restart. Call only from +// data.NewState. Idempotent for existing entries. // -// Idempotent for existing entries. +// Args: +// - commitQCs: half-open retained CommitQC range [First, Next). Seeds every +// epoch covering [First, Next), then EnsureDuoAt(Next). None = empty store +// → EnsureDuoAt(FirstRoad(1)) so {0,1}. Empty range (First >= Next) panics. +// - nextRoadToExecute: half-open execution tipcut (next road still needing +// execution). None = nothing executed. Past commit tipcut → warn, ignore. +// Present without commitQCs panics. // -// TODO(autobahn): nextRoadToExecute 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. -// TODO(autobahn): replace genesis placeholders with epoch info carried on blocks. +// TODO(autobahn): persist execution tip in Giga storage (stop using app DB). func (r *Registry) SetupInitialDuo( nextRoadToExecute utils.Option[types.RoadIndex], commitQCs utils.Option[types.RoadRange], @@ -180,13 +123,10 @@ func (r *Registry) SetupInitialDuo( if nextRoadToExecute.IsPresent() { panic("execution tipcut without CommitQC span on restart") } - // Fresh chain: AdvanceIfNeeded(LastRoad(0)) only seeds epoch 2; sealing - // epoch 0 WaitForDuo(FirstRoad(1)) needs epoch 1 registered first. r.EnsureDuoAt(FirstRoad(1)) } // FirstBlock returns the first global block number of the genesis epoch. -// Used as the cold-start default (no WAL, no snapshot); WAL overrides this on restart. func (r *Registry) FirstBlock() types.GlobalBlockNumber { for s := range r.state.RLock() { return s.m[0].FirstBlock() @@ -194,9 +134,8 @@ func (r *Registry) FirstBlock() types.GlobalBlockNumber { panic("unreachable") } -// EpochAt returns the epoch for the given road index. -// Returns an error if the epoch has not been registered via SetupInitialDuo or -// AdvanceIfNeeded. +// EpochAt returns the epoch containing roadIndex. +// Error if that epoch is not registered. func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { epochIdx := IndexForRoad(roadIndex) for s := range r.state.RLock() { @@ -208,11 +147,8 @@ func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { panic("unreachable") } -// makeEpoch constructs a new epoch at epochIdx using the genesis committee and -// 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. Panics if genesis (epoch 0) is missing — -// NewRegistry always installs it. +// makeEpoch inserts a genesis-committee placeholder at epochIdx. +// Caller holds the write lock. Overwrites if present. Panics without epoch 0. func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types.Epoch { genesis, ok := s.m[0] if !ok { @@ -221,8 +157,6 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types 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. if epochIdx > r.highestEpoch.Load() { r.highestEpoch.Store(epochIdx) } @@ -243,8 +177,7 @@ func (r *Registry) EnsureEpoch(idx types.EpochIndex) { } } -// EnsureDuoAt ensures epochs needed for DuoAt(road) (Current, and Prev when -// center > 0). +// EnsureDuoAt ensures epochs for DuoAt(road): Current, and Prev when center > 0. func (r *Registry) EnsureDuoAt(road types.RoadIndex) { center := IndexForRoad(road) if center > 0 { @@ -253,11 +186,9 @@ func (r *Registry) EnsureDuoAt(road types.RoadIndex) { r.EnsureEpoch(center) } -// EnsureExecTipcut restores registry lookahead for a half-open execution tipcut -// (next road that still needs execution). Roads strictly below next are fully -// done — same shape as a CommitQC tipcut. next==0 means nothing completed yet. -// Last completed road is next-1; AdvanceIfNeeded owns whether that road is -// LastRoad(epoch). +// EnsureExecTipcut seeds registry lookahead for a half-open execution tipcut +// (next road still needing execution). next==0 is a no-op. Otherwise last +// completed is next-1; seeds IndexForRoad(lastDone)+1 and AdvanceIfNeeded(lastDone). func (r *Registry) EnsureExecTipcut(next types.RoadIndex) { if next == 0 { return @@ -267,12 +198,9 @@ func (r *Registry) EnsureExecTipcut(next types.RoadIndex) { r.AdvanceIfNeeded(lastDone) } -// AdvanceIfNeeded seeds epoch M+2 when roadIndex is LastRoad(M) (design: finishing -// N-1 registers N+1, i.e. M=N-1 → M+2=N+1). Call only after the last global of -// that road has executed (GlobalRange.IsLastBlock) — the live path and data's -// execution tipcut (road+1 when complete) share that gate; this function owns -// the LastRoad(epoch) check. Earlier roads in the epoch are a no-op. -// Committee for M+2 is currently the genesis committee. +// AdvanceIfNeeded seeds epoch M+2 when roadIndex is LastRoad(M); else no-op. +// Call only after the last global of that road has executed (IsLastBlock). +// // TODO: pass the real M+2 committee once execution derives it. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { tipEpoch := IndexForRoad(roadIndex) @@ -283,12 +211,7 @@ func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { } // 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. +// Current must already be registered. Prev absent only for epoch 0. func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { centerIdx := IndexForRoad(roadIndex) current, err := r.EpochAt(FirstRoad(centerIdx)) @@ -304,9 +227,8 @@ func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { return types.NewEpochDuo(current, prev), nil } -// 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). +// WaitForDuo blocks until DuoAt(roadIndex) succeeds, then returns that duo. +// Must not hold the avail/data inner lock (execution may seed via AdvanceIfNeeded). func (r *Registry) WaitForDuo(ctx context.Context, roadIndex types.RoadIndex) (types.EpochDuo, error) { if duo, err := r.DuoAt(roadIndex); err == nil { return duo, nil From 5db53ed6b13222ec6ee6b4870504b158a037f4ca Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 19:36:21 -0700 Subject: [PATCH 13/85] fix(autobahn): wait tip order before PushQC EpochForRoad (CON-358) Avoid ErrRoadAfterWindow when a first QC of the next epoch races the boundary window slide. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/state.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index b4577841dd..56b1b9725f 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -461,12 +461,10 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma } // PushQC atomically admits qc and optional finalized blocks. -// EpochForRoad via epochDuo only (not Registry). Before-window hard-fails. +// Tip-order WaitUntil runs before EpochForRoad so a first QC of the next epoch +// waits for the boundary slide (rather than ErrRoadAfterWindow). Epoch via +// epochDuo only (not Registry); before-window hard-fails. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { - ep, err := s.epochDuo.Load().EpochForRoad(qc.QC().Proposal().Index()) - if err != nil { - return err - } gr := qc.QC().GlobalRange() needQC, err := func() (bool, error) { for inner, ctrl := range s.inner.Lock() { @@ -482,6 +480,10 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty if err != nil { return err } + ep, err := s.epochDuo.Load().EpochForRoad(qc.QC().Proposal().Index()) + if err != nil { + return err + } // Verify data. if needQC { if err := qc.Verify(ep); err != nil { From 7badc006ed96b48f1d841ad7e4f94dff223ff677 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 19:43:34 -0700 Subject: [PATCH 14/85] fix(autobahn): no-op PushQC before-window on already-applied QC (CON-358) Stale peer redelivery after the duo slides past should not tear down streams. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/state.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 56b1b9725f..38c8c7ee67 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -463,7 +463,9 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma // PushQC atomically admits qc and optional finalized blocks. // Tip-order WaitUntil runs before EpochForRoad so a first QC of the next epoch // waits for the boundary slide (rather than ErrRoadAfterWindow). Epoch via -// epochDuo only (not Registry); before-window hard-fails. +// epochDuo only (not Registry). Before-window on a still-needed QC hard-fails; +// if needQC is false the QC is already applied and a before-window miss is a +// no-op (stale peer redelivery after the duo slid — do not soft-admit via Registry). func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { gr := qc.QC().GlobalRange() needQC, err := func() (bool, error) { @@ -482,6 +484,9 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty } ep, err := s.epochDuo.Load().EpochForRoad(qc.QC().Proposal().Index()) if err != nil { + if !needQC && errors.Is(err, types.ErrRoadBeforeWindow) { + return nil + } return err } // Verify data. From 864cc31349535d509da5224f9dd6947301090fc5 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 20:08:59 -0700 Subject: [PATCH 15/85] docs(autobahn): explain epoch-0 seal prune-leash exemption (CON-358) Seal 0 drops nothing from the duo; restart-without-anchor is unreachable under BlocksPerLane. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 10 +++++++++- sei-tendermint/internal/autobahn/epoch/registry.go | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 3e79b54c69..fd0edfa016 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -323,8 +323,16 @@ func (s *State) waitPruneLeash(ctx context.Context, epochIdx types.EpochIndex, i } // waitCommitEpochLeashes enforces tip-interlock before sealing epoch N>0 -// (closingEpoch = last road of N). Mid-N admits are not gated. Epoch 0: no-op. +// (closingEpoch = last road of N). Mid-N admits are not gated. // incoming: AppQC on PushAppQC (None for PushCommitQC). See Registry invariants. +// +// Epoch 0 is exempt: sealing 0 does {∅,0}→{0,1} — nothing is dropped from the +// duo, so the prune leash (AppQC before dropping Prev) does not apply. Applying +// waitPruneLeash(0) would only block seal on the first AppQC for no Prev-drop +// reason. Restart with Current≥1 still requires a prune anchor (newInner); that +// path is unreachable here because lane production without an AppQC/prune is +// capped at BlocksPerLane ≪ EpochLength, so LastRoad(0) cannot be sealed +// without an earlier AppQC (and thus an anchor). func (s *State) waitCommitEpochLeashes( ctx context.Context, epochIdx types.EpochIndex, diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index df5712f338..0cca52093b 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -44,7 +44,8 @@ type registryState struct { // traffic; the registry may retain more epochs for restart and leashes. // - Execution cannot pass commit. Sealing epoch N (N>0) requires registry // N+1 (execution leash) and AppQC covering N-1 before Prev is dropped -// (prune leash). Finishing LastRoad(N-1) seeds epoch N+1 (AdvanceIfNeeded). +// (prune leash). Seal of epoch 0 is not prune-leashed: {∅,0}→{0,1} drops +// nothing. Finishing LastRoad(N-1) seeds epoch N+1 (AdvanceIfNeeded). // - data/ is the sole restart seeder (SetupInitialDuo). Avail/consensus must // not seed; tip into an unseeded epoch → EpochAt/DuoAt hard-fail. // - Post-construction tipcuts: avail ≥ consensus ≥ data.CommitTipCut(); From 42799098b4ab28c73b4573c2cacfa39c99d71619 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 21:14:22 -0700 Subject: [PATCH 16/85] refactor(autobahn): seed restart epochs from CommitQC span only (CON-358) Drop LastExecutedBlock/BlockDB QC lookup for SetupInitialDuo; always placeholder-seed windowLast+1 while committees are genesis stubs. Co-authored-by: Cursor --- .../internal/autobahn/avail/state_test.go | 27 ++-- .../internal/autobahn/consensus/inner_test.go | 9 +- .../internal/autobahn/consensus/state_test.go | 13 +- .../internal/autobahn/data/state.go | 33 +---- .../autobahn/data/state_recovery_test.go | 17 +++ .../internal/autobahn/epoch/registry.go | 46 +++---- .../internal/autobahn/epoch/registry_test.go | 129 +++--------------- .../internal/autobahn/epoch/testonly.go | 3 +- .../internal/p2p/giga_router_common.go | 1 - 9 files changed, 73 insertions(+), 205 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 423ff674ba..8067511702 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -26,14 +26,15 @@ var ( noCommitQCCB = utils.None[func(*types.CommitQC)]() ) -// 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. +// registerDuoAtEpoch installs Prev=n-1|Current=n as the state's operating +// window. Seeds those registry epochs only (no restart N+1 lookahead). func registerDuoAtEpoch(s *State, n types.EpochIndex) { r := s.data.Registry() - tip := epoch.FirstRoad(n) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - duo := utils.OrPanic1(r.DuoAt(tip)) + if n > 0 { + r.EnsureEpoch(n - 1) + } + r.EnsureEpoch(n) + duo := utils.OrPanic1(r.DuoAt(epoch.FirstRoad(n))) for inner := range s.inner.Lock() { inner.epochDuo.Store(duo) } @@ -1518,9 +1519,7 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { } // TestRestartDuoFromCommitTipNeedsSetup: DuoAt at FirstRoad(2) needs epoch 2. -// CommitQC alone in epoch 1 is not enough without an execution tipcut that -// advances past N-1; lead tips do not soft-seed. Also checks newInner still -// requires a prune anchor when Current > 0 (after SetupInitialDuo with execution). +// GenRegistryAt(…,0) only has {0,1}; lead tips do not soft-seed. func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} @@ -1529,15 +1528,15 @@ func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") } -// TestRestartDuoFromCommitTipNeedsSetup_ExecutionPath: closing CommitQC in -// epoch 1 plus execution tip still use SetupInitialDuo (data anchor). -func TestRestartDuoFromCommitTipNeedsSetup_ExecutionPath(t *testing.T) { +// TestRestartDuoFromClosingCommitQCSeedsNPlus1: closing CommitQC in epoch 1 +// seeds DuoAt(FirstRoad(2)) via SetupInitialDuo; newInner still needs a prune +// anchor when Current > 0. +func TestRestartDuoFromClosingCommitQCSeedsNPlus1(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} tip2 := epoch.FirstRoad(2) tip1 := epoch.LastRoad(1) - // Fully executed LastRoad(1) → execution tipcut FirstRoad(2). - registry.SetupInitialDuo(utils.Some(tip1+1), utils.Some(types.RoadRange{First: tip1, Next: tip1 + 1})) + registry.SetupInitialDuo(utils.Some(types.RoadRange{First: tip1, Next: tip1 + 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_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 53b50454c2..3017333f16 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -73,8 +73,8 @@ func TestNewInnerEmpty(t *testing.T) { } // TestNewInner_ConsensusTipLeadsDataWindow: data CommitQC tip is in epoch 1; -// LastExecutedBlock in the same epoch means N-1 is done → EnsureExecTipcut -// seeds epoch 2. Persisted CommitQC is LastRoad(1) → view tipcut FirstRoad(2). +// EnsureExecTipcut seeds epoch 2 (live lookahead). Persisted CommitQC is +// LastRoad(1) → view tipcut FirstRoad(2). func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) // {0,1} only @@ -82,9 +82,8 @@ func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { closing := epoch.LastRoad(1) tipcut := epoch.FirstRoad(2) - // data.NewState: LastExecutedBlock in epoch 1 (same as CommitQC tip) → seed 2. - registry.EnsureExecTipcut(epoch.FirstRoad(1)) // execution tipcut still on first road of epoch 1 - + // Live path: execution tipcut still on first road of epoch 1 → seed 2. + registry.EnsureExecTipcut(epoch.FirstRoad(1)) vote := types.NewCommitVote(types.ProposalAt(ep1, types.View{Index: closing})) votes := make([]*types.Signed[*types.CommitVote], len(keys)) for i, k := range keys { diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 4fcb0bcde6..ebc27da042 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -363,11 +363,10 @@ func fullCommitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadI // TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1 is the end-to-end restart // path for tip interlocking: data CommitQC BlockDB tip stays in epoch N while -// avail/consensus tips are already at FirstRoad(N+1). LastExecutedBlock is in -// the same epoch as the data tip → N-1 is done → SetupInitialDuo seeds N+1. +// avail/consensus tips are already at FirstRoad(N+1). SetupInitialDuo always +// seeds placeholder N+1 past the CommitQC window. func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { rng := utils.TestRng() - // FirstBlock must be >0: nextRoadToExecute treats LastExecutedBlock 0 as unset. sks := utils.GenSliceN(rng, 4, types.GenSecretKey) weights := map[types.PublicKey]uint64{} for _, sk := range sks { @@ -375,7 +374,7 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { } committee := utils.OrPanic1(types.NewCommittee(weights)) registry := utils.OrPanic1(epoch.NewRegistry(committee, 1, time.Time{})) - registry.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadRange]()) + registry.SetupInitialDuo(utils.None[types.RoadRange]()) registry.EnsureEpoch(1) keys := sks ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) @@ -418,13 +417,9 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { seedPersistedInner(stateDir, &persistedInner{CommitQC: utils.Some(closingQC)}) leadTip := epoch.FirstRoad(nPlus1) - // Same epoch as data tip → EnsureExecTipcut seeds N+1. - lastExec := dataQC.QC().GlobalRange().First - require.Positive(t, lastExec, "LastExecutedBlock 0 is the unset sentinel") db2 := newTestBlockDB(t, dataDir) ds := utils.OrPanic1(data.NewState(&data.Config{ - Registry: registry, - LastExecutedBlock: lastExec, + Registry: registry, }, db2)) dataTip := ds.CommitTipCut() require.Equal(t, n, epoch.IndexForRoad(dataTip), "data tipcut must stay in epoch N") diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 38c8c7ee67..4df86a0bd7 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -196,16 +196,11 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { blockDB: blockDB, } // Seed epochs before replay (data owns SetupInitialDuo). - // TODO(autobahn): persist execution tip in Giga storage (stop using app DB). commitQCs, err := commitQCSpan(blockDB) if err != nil { return nil, fmt.Errorf("scan CommitQC span: %w", err) } - nextRoad, err := s.nextRoadToExecute(blockDB) - if err != nil { - return nil, fmt.Errorf("resolve next road to execute: %w", err) - } - cfg.Registry.SetupInitialDuo(nextRoad, commitQCs) + cfg.Registry.SetupInitialDuo(commitQCs) if err := s.loadFromBlockDB(blockDB); err != nil { return nil, fmt.Errorf("loadFromBlockDB: %w", err) @@ -265,32 +260,6 @@ func boundaryQCRoad(blockDB types.BlockDB, reverse bool) (types.RoadIndex, bool, return qc.QC().Proposal().Index(), true, nil } -// nextRoadToExecute is the half-open execution tipcut. Covering QC road R: -// mid-range → R; IsLastBlock → R+1. LastExecutedBlock 0 → None. Positive height -// with missing/pruned covering QC → error. -func (s *State) nextRoadToExecute(blockDB types.BlockDB) (utils.Option[types.RoadIndex], error) { - n := s.cfg.LastExecutedBlock - if n == 0 { - return utils.None[types.RoadIndex](), nil - } - opt, err := blockDB.ReadQCByBlockNumber(n) - if err != nil { - if errors.Is(err, types.ErrPruned) || errors.Is(err, types.ErrNotFound) { - return utils.None[types.RoadIndex](), fmt.Errorf("covering QC for executed block %d missing or pruned", n) - } - return utils.None[types.RoadIndex](), fmt.Errorf("read QC for executed block %d: %w", n, err) - } - qc, ok := opt.Get() - if !ok { - return utils.None[types.RoadIndex](), fmt.Errorf("covering QC for executed block %d missing or pruned", n) - } - road := qc.QC().Proposal().Index() - if qc.QC().GlobalRange().IsLastBlock(n) { - return utils.Some(road + 1), nil - } - return utils.Some(road), nil -} - // loadFromBlockDB replays QCs and blocks from blockDB into s.inner. // Called from NewState before any goroutines are spawned; the lock is acquired // only to satisfy the Watch API. diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index 1348909b8e..6eb2f8dce1 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -593,3 +593,20 @@ func TestRecoveryBlockGap(t *testing.T) { require.NoError(t, err) require.Equal(t, mid, state.NextBlock(), "replay must resume at the first unfilled number") } + +// TestNewState_AppLeadsBlockDBFlush: app Commit can lead BlockDB flush; NewState +// still boots from the CommitQC span + placeholder N+1 (no LastExecuted lookup). +func TestNewState_AppLeadsBlockDBFlush(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + gr1 := qc1.QC().GlobalRange() + + db := memblock.NewBlockDB() + writeToBlockDB(t, db, []*types.FullCommitQC{qc1}, [][]*types.Block{blocks1}) + tips := db.Status() + require.Equal(t, gr1.Next, tips.NextBlock, "NextBlock is first height of the next QC") + + _, err := NewState(&Config{Registry: registry}, db) + require.NoError(t, err) +} diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 0cca52093b..fc760c8ba3 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -3,16 +3,12 @@ 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 @@ -79,19 +75,14 @@ func NewRegistry( // SetupInitialDuo seeds placeholder epochs on restart. Call only from // data.NewState. Idempotent for existing entries. // -// Args: -// - commitQCs: half-open retained CommitQC range [First, Next). Seeds every -// epoch covering [First, Next), then EnsureDuoAt(Next). None = empty store -// → EnsureDuoAt(FirstRoad(1)) so {0,1}. Empty range (First >= Next) panics. -// - nextRoadToExecute: half-open execution tipcut (next road still needing -// execution). None = nothing executed. Past commit tipcut → warn, ignore. -// Present without commitQCs panics. +// commitQCs is the half-open retained CommitQC range [First, Next). Seeds every +// epoch covering [First, Next), EnsureDuoAt(Next), then EnsureEpoch(windowLast+1) +// (placeholder N+1 lookahead). None = empty store → EnsureDuoAt(FirstRoad(1)) +// so {0,1}. Empty range (First >= Next) panics. // -// TODO(autobahn): persist execution tip in Giga storage (stop using app DB). -func (r *Registry) SetupInitialDuo( - nextRoadToExecute utils.Option[types.RoadIndex], - commitQCs utils.Option[types.RoadRange], -) { +// TODO(autobahn): when committees come from execution results, stop inventing +// placeholder N+1 — only register epochs whose committees are known. +func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) { if span, ok := commitQCs.Get(); ok { if span.First >= span.Next { panic(fmt.Sprintf("SetupInitialDuo: empty CommitQC range [%d, %d)", span.First, span.Next)) @@ -108,22 +99,11 @@ func (r *Registry) SetupInitialDuo( } } r.EnsureDuoAt(span.Next) - - if next, ok := nextRoadToExecute.Get(); ok { - if next > span.Next { - logger.Warn("execution tipcut past CommitQC tipcut on restart; ignoring for epoch seeding", - slog.Uint64("execution_tipcut", uint64(next)), - slog.Uint64("commit_qc_tipcut", uint64(span.Next))) - } else { - r.EnsureExecTipcut(next) - } - } + // Placeholder N+1 past the CommitQC window. Harmless with genesis committees. + r.EnsureEpoch(windowLast + 1) return } - if nextRoadToExecute.IsPresent() { - panic("execution tipcut without CommitQC span on restart") - } r.EnsureDuoAt(FirstRoad(1)) } @@ -190,6 +170,10 @@ func (r *Registry) EnsureDuoAt(road types.RoadIndex) { // EnsureExecTipcut seeds registry lookahead for a half-open execution tipcut // (next road still needing execution). next==0 is a no-op. Otherwise last // completed is next-1; seeds IndexForRoad(lastDone)+1 and AdvanceIfNeeded(lastDone). +// +// TODO(autobahn): placeholder N+1 lookahead is OK with genesis committees. +// When committees come from execution results, only seed epochs whose +// committees are known from durable execute output. func (r *Registry) EnsureExecTipcut(next types.RoadIndex) { if next == 0 { return @@ -202,7 +186,9 @@ func (r *Registry) EnsureExecTipcut(next types.RoadIndex) { // AdvanceIfNeeded seeds epoch M+2 when roadIndex is LastRoad(M); else no-op. // Call only after the last global of that road has executed (IsLastBlock). // -// TODO: pass the real M+2 committee once execution derives it. +// TODO(autobahn): pass the real M+2 committee once execution derives it. +// Until then placeholder committees may seed ahead of real execute results +// (including restart when app Commit leads blockDB flush). func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { tipEpoch := IndexForRoad(roadIndex) if roadIndex != LastRoad(tipEpoch) { diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index c92b0dd221..11c2400b92 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -84,16 +84,16 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { } } -func TestSetupInitialDuo_EmptyNoneNoneSeedsGenesisNeighbor(t *testing.T) { +func TestSetupInitialDuo_EmptyNoneSeedsGenesisNeighbor(t *testing.T) { r, _ := makeRegistry(t) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadRange]()) + r.SetupInitialDuo(utils.None[types.RoadRange]()) for _, idx := range []types.EpochIndex{0, 1} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after empty seeding: %v", idx, err) } } if _, err := r.EpochAt(FirstRoad(2)); err == nil { - t.Fatal("EpochAt(epoch 2) should not be present from empty None/None seeding") + t.Fatal("EpochAt(epoch 2) should not be present from empty None seeding") } } @@ -104,29 +104,29 @@ func TestSetupInitialDuo_EmptyRangePanics(t *testing.T) { t.Fatal("empty CommitQC range must panic") } }() - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: 5, Next: 5})) + r.SetupInitialDuo(utils.Some(types.RoadRange{First: 5, Next: 5})) } -func TestSetupInitialDuo_CommitQCOnly(t *testing.T) { +func TestSetupInitialDuo_CommitQCMidSeedsPlaceholderNext(t *testing.T) { r, _ := makeRegistry(t) - // Single tip mid-5 + tipcut EnsureDuoAt → {4,5}. + // Mid-5 CommitQC + tipcut EnsureDuoAt → {4,5}; always EnsureEpoch(windowLast+1) → 6. tip := midRoad(5) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - for _, idx := range []types.EpochIndex{4, 5} { + r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + for _, idx := range []types.EpochIndex{4, 5, 6} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after CommitQC seeding: %v", idx, err) } } - if _, err := r.EpochAt(FirstRoad(6)); err == nil { - t.Fatal("EpochAt(epoch 6) should not be present from CommitQC alone") + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present from mid-epoch CommitQC") } } func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { r, _ := makeRegistry(t) - // Closing tip: {5} + tipcut FirstRoad(6) → {5,6}. + // Closing tip: EnsureDuoAt(FirstRoad(6)) → {5,6}; EnsureEpoch(6) is redundant. tip := LastRoad(5) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) 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) @@ -142,12 +142,12 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { func TestSetupInitialDuo_CommitSpanFromFirst(t *testing.T) { r, _ := makeRegistry(t) - // Span mid-2..mid-5 → seed epochs 2..5 only (not open from 0). - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{ + // Span mid-2..mid-5 → seed epochs 2..5, then placeholder windowLast+1 → 6. + r.SetupInitialDuo(utils.Some(types.RoadRange{ First: midRoad(2), Next: midRoad(5) + 1, })) - for _, idx := range []types.EpochIndex{2, 3, 4, 5} { + for _, idx := range []types.EpochIndex{2, 3, 4, 5, 6} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after commit span seeding: %v", idx, err) } @@ -155,103 +155,8 @@ func TestSetupInitialDuo_CommitSpanFromFirst(t *testing.T) { if _, err := r.EpochAt(FirstRoad(1)); err == nil { t.Fatal("EpochAt(epoch 1) should not be present when span.First is in epoch 2") } - if _, err := r.EpochAt(FirstRoad(6)); err == nil { - t.Fatal("EpochAt(epoch 6) should not be present from mid-epoch commit tipcut") - } -} - -func TestSetupInitialDuo_ExecutionExtendsForward(t *testing.T) { - r, _ := makeRegistry(t) - tip := midRoad(5) - // Tip mid-5 + CommitQC tipcut → {4,5}; execution tipcut mid-5 → lastDone mid-5-1 → epoch 6. - r.SetupInitialDuo(utils.Some(tip), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - 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_ExecutionClosingAddsNextNext(t *testing.T) { - r, _ := makeRegistry(t) - tip := LastRoad(5) - // Closing road fully executed → execution tipcut tip+1 → AdvanceIfNeeded → 6 and 7. - r.SetupInitialDuo(utils.Some(tip+1), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - 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) - } - } - if _, err := r.EpochAt(FirstRoad(8)); err == nil { - t.Fatal("EpochAt(epoch 8) should not be present from closing-road execution") - } -} - -// TestSetupInitialDuo_MidClosingRoadDoesNotSeedNextNext: execution tipcut still -// on LastRoad(E) (mid-range) must seed E+1 only — tipcut not past the road, so -// lastDone is not LastRoad(E). Mid-road crash must not unlock E+2. -func TestSetupInitialDuo_MidClosingRoadDoesNotSeedNextNext(t *testing.T) { - r, _ := makeRegistry(t) - tip := LastRoad(5) - r.SetupInitialDuo(utils.Some(tip), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - for _, idx := range []types.EpochIndex{5, 6} { - if _, err := r.EpochAt(FirstRoad(idx)); err != nil { - t.Fatalf("EpochAt(epoch %d) after mid-closing execution: %v", idx, err) - } - } if _, err := r.EpochAt(FirstRoad(7)); err == nil { - t.Fatal("EpochAt(epoch 7) must not be present when LastRoad(E) is only mid-executed") - } -} - -// TestSetupInitialDuo_ExecutedSameEpochAsTipSeedsNext: CommitQC tip in epoch N; -// LastExecuted also in N ⇒ N-1 done ⇒ EnsureExecTipcut seeds N+1. -func TestSetupInitialDuo_ExecutedSameEpochAsTipSeedsNext(t *testing.T) { - r, _ := makeRegistry(t) // {0} - n := types.EpochIndex(1) - tip := FirstRoad(n) - r.SetupInitialDuo(utils.Some(tip), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - if _, err := r.EpochAt(FirstRoad(n + 1)); err != nil { - t.Fatalf("EpochAt(N+1) when executed in tip epoch N: %v", err) - } - if _, err := r.EpochAt(FirstRoad(n + 2)); err == nil { - t.Fatal("EpochAt(N+2) should not be present from mid-N execution") - } -} - -func TestSetupInitialDuo_ExecutionPastCommitQCIgnored(t *testing.T) { - r, _ := makeRegistry(t) - tip := midRoad(3) - // Commit tip mid-3; execution mid-5 past tipcut → warn, ignore. - r.SetupInitialDuo(utils.Some(midRoad(5)), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - 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) - // Commit span mid-2..mid-5; lagging execution mid-3 must not drop 4/5. - r.SetupInitialDuo(utils.Some(midRoad(3)), utils.Some(types.RoadRange{ - First: midRoad(2), - Next: midRoad(5) + 1, - })) - 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) - } - } - // Lagging mid-3 EnsureExecTipcut → 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") + t.Fatal("EpochAt(epoch 7) should not be present past placeholder windowLast+1") } } @@ -272,7 +177,7 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { func TestDuoAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) tip := midRoad(2) - r.SetupInitialDuo(utils.None[types.RoadIndex](), utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) duo, err := r.DuoAt(FirstRoad(2)) if err != nil { t.Fatalf("DuoAt(epoch 2) error: %v", err) diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 2d07ec410c..41fe26bb88 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -55,8 +55,7 @@ func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Regis func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumber, startEpoch types.EpochIndex) *Registry { registry := utils.OrPanic1(NewRegistry(committee, firstBlock, time.Now())) - // Same empty-BlockDB default as data.NewState: None/None seeds {0,1}. - registry.SetupInitialDuo(utils.None[types.RoadIndex](), utils.None[types.RoadRange]()) + registry.SetupInitialDuo(utils.None[types.RoadRange]()) // Ensure at least {0,1} so DuoAt(FirstRoad(1)) works in tests. through := startEpoch if through < 1 { diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 4826225cdf..4caee24e64 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -281,7 +281,6 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } // Seed N+2 when the last global of an epoch's closing road is executed. - // IsLastBlock matches data.nextRoadToExecute advancing to road+1; // AdvanceIfNeeded owns LastRoad(epoch). Empty tipcuts are rejected by // Proposal.Verify, so every closing road has a last global. // TODO: real N+2 committee once execution derives it. From 89898b90d4401da19c9420f1114ee4ff07d9042a Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 21:37:00 -0700 Subject: [PATCH 17/85] fix(autobahn): always placeholder-seed windowLast+2 on restart (CON-358) Covers LastRoad(N) restart without exec tip while committees stay genesis stubs. Co-authored-by: Cursor --- .../internal/autobahn/consensus/state_test.go | 2 +- .../autobahn/data/state_recovery_test.go | 2 +- .../internal/autobahn/epoch/registry.go | 13 +++++----- .../internal/autobahn/epoch/registry_test.go | 24 +++++++++---------- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index ebc27da042..d4406175b6 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -364,7 +364,7 @@ func fullCommitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadI // TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1 is the end-to-end restart // path for tip interlocking: data CommitQC BlockDB tip stays in epoch N while // avail/consensus tips are already at FirstRoad(N+1). SetupInitialDuo always -// seeds placeholder N+1 past the CommitQC window. +// seeds placeholder N+1/N+2 past the CommitQC window. func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { rng := utils.TestRng() sks := utils.GenSliceN(rng, 4, types.GenSecretKey) diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index 6eb2f8dce1..9d08f61655 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -595,7 +595,7 @@ func TestRecoveryBlockGap(t *testing.T) { } // TestNewState_AppLeadsBlockDBFlush: app Commit can lead BlockDB flush; NewState -// still boots from the CommitQC span + placeholder N+1 (no LastExecuted lookup). +// still boots from the CommitQC span + placeholder N+1/N+2 (no LastExecuted lookup). func TestNewState_AppLeadsBlockDBFlush(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index fc760c8ba3..e0a2a1d31c 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -76,12 +76,9 @@ func NewRegistry( // data.NewState. Idempotent for existing entries. // // commitQCs is the half-open retained CommitQC range [First, Next). Seeds every -// epoch covering [First, Next), EnsureDuoAt(Next), then EnsureEpoch(windowLast+1) -// (placeholder N+1 lookahead). None = empty store → EnsureDuoAt(FirstRoad(1)) +// epoch covering [First, Next), EnsureDuoAt(Next), then placeholder +// windowLast+1/+2 (see below). None = empty store → EnsureDuoAt(FirstRoad(1)) // so {0,1}. Empty range (First >= Next) panics. -// -// TODO(autobahn): when committees come from execution results, stop inventing -// placeholder N+1 — only register epochs whose committees are known. func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) { if span, ok := commitQCs.Get(); ok { if span.First >= span.Next { @@ -99,8 +96,12 @@ func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) { } } r.EnsureDuoAt(span.Next) - // Placeholder N+1 past the CommitQC window. Harmless with genesis committees. + // Placeholder +1/+2: simplification while committees are genesis stubs + // (unchanged by exec). Covers exec tip ahead of persisted CommitQC (N+1) + // and tip at LastRoad(N) without re-exec (N+2). Goes away next PR when + // committees are linked to execution. r.EnsureEpoch(windowLast + 1) + r.EnsureEpoch(windowLast + 2) return } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 11c2400b92..fcb4cf5bad 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -109,25 +109,25 @@ func TestSetupInitialDuo_EmptyRangePanics(t *testing.T) { func TestSetupInitialDuo_CommitQCMidSeedsPlaceholderNext(t *testing.T) { r, _ := makeRegistry(t) - // Mid-5 CommitQC + tipcut EnsureDuoAt → {4,5}; always EnsureEpoch(windowLast+1) → 6. + // Mid-5 CommitQC + tipcut EnsureDuoAt → {4,5}; always through windowLast+2 → 6,7. tip := midRoad(5) r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - 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 CommitQC seeding: %v", idx, err) } } - if _, err := r.EpochAt(FirstRoad(7)); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present from mid-epoch CommitQC") + if _, err := r.EpochAt(FirstRoad(8)); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present from mid-epoch CommitQC") } } func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { r, _ := makeRegistry(t) - // Closing tip: EnsureDuoAt(FirstRoad(6)) → {5,6}; EnsureEpoch(6) is redundant. + // Closing tip: EnsureDuoAt(FirstRoad(6)) → {5,6}; windowLast+2 → 7. tip := LastRoad(5) r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - for _, idx := range []types.EpochIndex{5, 6} { + for _, idx := range []types.EpochIndex{5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after closing CommitQC: %v", idx, err) } @@ -135,19 +135,19 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { 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") + if _, err := r.EpochAt(FirstRoad(8)); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present past windowLast+2") } } func TestSetupInitialDuo_CommitSpanFromFirst(t *testing.T) { r, _ := makeRegistry(t) - // Span mid-2..mid-5 → seed epochs 2..5, then placeholder windowLast+1 → 6. + // Span mid-2..mid-5 → seed epochs 2..5, then placeholder through windowLast+2 → 7. r.SetupInitialDuo(utils.Some(types.RoadRange{ First: midRoad(2), Next: midRoad(5) + 1, })) - for _, idx := range []types.EpochIndex{2, 3, 4, 5, 6} { + for _, idx := range []types.EpochIndex{2, 3, 4, 5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after commit span seeding: %v", idx, err) } @@ -155,8 +155,8 @@ func TestSetupInitialDuo_CommitSpanFromFirst(t *testing.T) { if _, err := r.EpochAt(FirstRoad(1)); err == nil { t.Fatal("EpochAt(epoch 1) should not be present when span.First is in epoch 2") } - if _, err := r.EpochAt(FirstRoad(7)); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present past placeholder windowLast+1") + if _, err := r.EpochAt(FirstRoad(8)); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present past placeholder windowLast+2") } } From a17db0a3c3f7922a7413183e10cfb0c3585e073c Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 21:56:17 -0700 Subject: [PATCH 18/85] fix(autobahn): hard-fail DuoAt when non-genesis Prev is missing (CON-358) Match consensus newInner: do not soft-degrade to a Current-only window. Co-authored-by: Cursor --- .../internal/autobahn/epoch/registry.go | 9 ++++-- .../internal/autobahn/epoch/registry_test.go | 29 ++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index e0a2a1d31c..4ea0fab97a 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -199,7 +199,8 @@ func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { } // DuoAt returns the EpochDuo centered on the epoch containing roadIndex. -// Current must already be registered. Prev absent only for epoch 0. +// Current must already be registered. Prev absent only for epoch 0; missing +// Prev for center > 0 is a hard error (no soft-degrade to Current-only). func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { centerIdx := IndexForRoad(roadIndex) current, err := r.EpochAt(FirstRoad(centerIdx)) @@ -208,9 +209,11 @@ func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { } prev := utils.None[*types.Epoch]() if centerIdx > 0 { - if p, err := r.EpochAt(FirstRoad(centerIdx - 1)); err == nil { - prev = utils.Some(p) + p, err := r.EpochAt(FirstRoad(centerIdx - 1)) + if err != nil { + return types.EpochDuo{}, fmt.Errorf("epoch %d prev (road %d) not in registry", centerIdx-1, roadIndex) } + prev = utils.Some(p) } return types.NewEpochDuo(current, prev), nil } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index fcb4cf5bad..0ac1c997eb 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -69,7 +69,7 @@ func TestEpochAt_ErrorIfNotRegistered(t *testing.T) { func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { r, _ := makeRegistry(t) - // NewRegistry already seeds {0,1}. Only the last road of epoch 0 seeds epoch 2. + // NewRegistry seeds {0}. Only the last road of epoch 0 seeds epoch 2. r.AdvanceIfNeeded(0) if _, err := r.EpochAt(FirstRoad(2)); err == nil { t.Fatal("AdvanceIfNeeded(0) must not seed epoch 2") @@ -209,14 +209,34 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { } } +func TestDuoAt_ErrorWhenPrevMissing(t *testing.T) { + committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + types.GenSecretKey(utils.TestRng()).Public(): 1, + })) + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, time.Time{}, committee, 0) + ep2 := types.NewEpoch(2, types.RoadRange{First: FirstRoad(2), Next: FirstRoad(3)}, time.Time{}, committee, 0) + // Gap: epoch 2 present without epoch 1. + bare := &Registry{ + state: utils.NewRWMutex(®istryState{ + m: map[types.EpochIndex]*types.Epoch{0: ep0, 2: ep2}, + latest: 2, + }), + highestEpoch: utils.NewAtomicSend(types.EpochIndex(2)), + } + _, err := bare.DuoAt(FirstRoad(2)) + if err == nil { + t.Fatal("DuoAt(FirstRoad(2)) expected error when Prev epoch not registered, got nil") + } +} + func TestWaitForDuo_FastPathAndWait(t *testing.T) { r, _ := makeRegistry(t) - // NewRegistry seeds {0,1}; DuoAt(0) is immediate. + // NewRegistry seeds {0}; 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 2 needs epoch 2 registered (seeded by executing epoch 0). + // Tipcut into epoch 2 needs {1,2} registered. tip := FirstRoad(2) _, err = r.DuoAt(tip) require.Error(t, err) @@ -230,7 +250,8 @@ func TestWaitForDuo_FastPathAndWait(t *testing.T) { duo, err := r.WaitForDuo(t.Context(), tip) done <- result{duo, err} }() - r.AdvanceIfNeeded(LastRoad(0)) // last road of epoch 0 seeds epoch 2 + r.EnsureEpoch(1) // Prev for DuoAt(epoch 2) + r.AdvanceIfNeeded(LastRoad(0)) // seeds epoch 2 got := <-done require.NoError(t, got.err) require.Equal(t, types.EpochIndex(2), got.duo.Current.EpochIndex()) From 6b4280216e2e922e0de6be42572f54e14865c92f Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 23 Jul 2026 22:07:30 -0700 Subject: [PATCH 19/85] refactor(autobahn): remove unused EnsureExecTipcut (CON-358) Restart seeding uses CommitQC-span placeholders; live lookahead stays on AdvanceIfNeeded. Co-authored-by: Cursor --- .../internal/autobahn/consensus/inner_test.go | 6 +++--- .../internal/autobahn/epoch/registry.go | 16 ---------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 3017333f16..8307e34a31 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -73,7 +73,7 @@ func TestNewInnerEmpty(t *testing.T) { } // TestNewInner_ConsensusTipLeadsDataWindow: data CommitQC tip is in epoch 1; -// EnsureExecTipcut seeds epoch 2 (live lookahead). Persisted CommitQC is +// registry has epoch 2 (live AdvanceIfNeeded lookahead). Persisted CommitQC is // LastRoad(1) → view tipcut FirstRoad(2). func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { rng := utils.TestRng() @@ -82,8 +82,8 @@ func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { closing := epoch.LastRoad(1) tipcut := epoch.FirstRoad(2) - // Live path: execution tipcut still on first road of epoch 1 → seed 2. - registry.EnsureExecTipcut(epoch.FirstRoad(1)) + // Live path after finishing LastRoad(0): seed epoch 2. + registry.EnsureEpoch(2) vote := types.NewCommitVote(types.ProposalAt(ep1, types.View{Index: closing})) votes := 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 4ea0fab97a..28e8b5a0af 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -168,22 +168,6 @@ func (r *Registry) EnsureDuoAt(road types.RoadIndex) { r.EnsureEpoch(center) } -// EnsureExecTipcut seeds registry lookahead for a half-open execution tipcut -// (next road still needing execution). next==0 is a no-op. Otherwise last -// completed is next-1; seeds IndexForRoad(lastDone)+1 and AdvanceIfNeeded(lastDone). -// -// TODO(autobahn): placeholder N+1 lookahead is OK with genesis committees. -// When committees come from execution results, only seed epochs whose -// committees are known from durable execute output. -func (r *Registry) EnsureExecTipcut(next types.RoadIndex) { - if next == 0 { - return - } - lastDone := next - 1 - r.EnsureEpoch(IndexForRoad(lastDone) + 1) - r.AdvanceIfNeeded(lastDone) -} - // AdvanceIfNeeded seeds epoch M+2 when roadIndex is LastRoad(M); else no-op. // Call only after the last global of that road has executed (IsLastBlock). // From e44104843134d079c27ad4e097b5e4289512a370 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 07:41:59 -0700 Subject: [PATCH 20/85] refactor(autobahn): DuoAt in newInner and RoadRange.IsLastRoad (CON-358) Reuse registry duo construction on restart; name the tipcut seal predicate shared by avail/data. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch.go | 5 ++++ sei-tendermint/autobahn/types/epoch_test.go | 23 +++++++++++++++++++ .../internal/autobahn/avail/state.go | 8 +++---- .../internal/autobahn/consensus/inner.go | 11 +++------ .../internal/autobahn/data/state.go | 2 +- 5 files changed, 36 insertions(+), 13 deletions(-) create mode 100644 sei-tendermint/autobahn/types/epoch_test.go diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index d32d071c4f..d24f37a70b 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -23,6 +23,11 @@ func OpenRoadRange() RoadRange { return RoadRange{First: 0, Next: utils.Max[Road // Has reports whether idx falls within this range [First, Next). func (r RoadRange) Has(idx RoadIndex) bool { return idx >= r.First && idx < r.Next } +// IsLastRoad reports whether idx is the last road in this half-open range +// (idx+1 == Next). Tipcut seal / duo slide keys off this — same shape as +// GlobalRange.IsLastBlock. +func (r RoadRange) IsLastRoad(idx RoadIndex) bool { return idx+1 == r.Next } + // 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_test.go b/sei-tendermint/autobahn/types/epoch_test.go new file mode 100644 index 0000000000..db91d7fa73 --- /dev/null +++ b/sei-tendermint/autobahn/types/epoch_test.go @@ -0,0 +1,23 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRoadRange_Has(t *testing.T) { + r := RoadRange{First: 10, Next: 13} + require.False(t, r.Has(9)) + require.True(t, r.Has(10)) + require.True(t, r.Has(12)) + require.False(t, r.Has(13)) +} + +func TestRoadRange_IsLastRoad(t *testing.T) { + r := RoadRange{First: 10, Next: 13} // covers 10, 11, 12 + require.False(t, r.IsLastRoad(10)) + require.False(t, r.IsLastRoad(11)) + require.True(t, r.IsLastRoad(12)) + require.False(t, r.IsLastRoad(13)) +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index fd0edfa016..0908618ec2 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -409,7 +409,7 @@ 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) } - closing := idx+1 == ep.RoadRange().Next + closing := ep.RoadRange().IsLastRoad(idx) if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex(), closing, utils.None[*types.AppQC]()); err != nil { return err } @@ -417,7 +417,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 == ep.RoadRange().Next { + if closing { nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) if err != nil { return err @@ -521,13 +521,13 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ } // Same leashes as PushCommitQC: tipcut pushBack is a CommitQC insert path. // Pass this AppQC as incoming so a tipcut that first enters epoch N can close N. - closing := idx+1 == ep.RoadRange().Next + closing := ep.RoadRange().IsLastRoad(idx) 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. var nextDuo *types.EpochDuo - if idx+1 == ep.RoadRange().Next { + if closing { nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) if err != nil { return err diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 610d61e15a..805ac5b7e3 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -140,15 +140,10 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - prevOpt := utils.None[*types.Epoch]() - if viewEpoch.EpochIndex() > 0 { - prev, err := registry.EpochAt(epoch.FirstRoad(viewEpoch.EpochIndex() - 1)) - if err != nil { - return inner{}, fmt.Errorf("EpochAt(prev): %w", err) - } - prevOpt = utils.Some(prev) + duo, err := registry.DuoAt(nextViewRoad) + if err != nil { + return inner{}, fmt.Errorf("DuoAt(%d): %w", nextViewRoad, err) } - duo := types.NewEpochDuo(viewEpoch, prevOpt) return inner{persistedInner: persisted, epochs: duo}, nil } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 4df86a0bd7..b8fb6be039 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -477,7 +477,7 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty idx := qc.QC().Proposal().Index() var nextDuo *types.EpochDuo duo := s.epochDuo.Load() - if needQC && idx+1 == duo.Current.RoadRange().Next { + if needQC && duo.Current.RoadRange().IsLastRoad(idx) { nt, err := s.cfg.Registry.WaitForDuo(ctx, idx+1) if err != nil { return err From d335b4c31c5595d26024cc28864f8bac32a46ede Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 10:47:37 -0700 Subject: [PATCH 21/85] fix(autobahn): admit-duo verify snapshot and WaitForDuo gap wake (CON-358) Return the admitted EpochDuo for Push* verify, wake WaitForDuo on any registration (not only highest), and drop zero-weight lane votes without retaining them for reweight. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/app_qc.go | 13 ++- .../autobahn/types/committee_test.go | 6 +- sei-tendermint/autobahn/types/epoch_duo.go | 11 +-- .../autobahn/types/epoch_duo_test.go | 9 -- sei-tendermint/autobahn/types/proposal.go | 12 +-- .../internal/autobahn/avail/block_votes.go | 26 +++--- .../autobahn/avail/block_votes_test.go | 85 +++++++++++++------ .../internal/autobahn/avail/state.go | 70 +++++++++------ .../internal/autobahn/avail/state_test.go | 10 +-- .../internal/autobahn/data/state.go | 6 +- .../internal/autobahn/epoch/registry.go | 34 +++++--- .../internal/autobahn/epoch/registry_test.go | 32 ++++++- 12 files changed, 192 insertions(+), 122 deletions(-) diff --git a/sei-tendermint/autobahn/types/app_qc.go b/sei-tendermint/autobahn/types/app_qc.go index af7471728f..712598b766 100644 --- a/sei-tendermint/autobahn/types/app_qc.go +++ b/sei-tendermint/autobahn/types/app_qc.go @@ -35,8 +35,17 @@ func (m *AppQC) Next() RoadIndex { return m.Proposal().Next() } -// Verify verifies the AppQC against the committee. -func (m *AppQC) Verify(c *Committee) error { +func (m *AppQC) Verify(duo EpochDuo) error { + p := m.Proposal() + ep, err := duo.EpochForIndex(p.EpochIndex()) + if err != nil { + return err + } + if rr := ep.RoadRange(); !rr.Has(p.RoadIndex()) { + return fmt.Errorf("app road_index %v not in epoch %d roads [%v,%v)", + p.RoadIndex(), ep.EpochIndex(), rr.First, rr.Next) + } + c := ep.Committee() return m.vote.verifyQC(c, c.AppQuorum(), m.sigs) } diff --git a/sei-tendermint/autobahn/types/committee_test.go b/sei-tendermint/autobahn/types/committee_test.go index 413568bb86..2364ccc507 100644 --- a/sei-tendermint/autobahn/types/committee_test.go +++ b/sei-tendermint/autobahn/types/committee_test.go @@ -171,18 +171,18 @@ func TestCommitQCVerifyChecksWeight(t *testing.T) { func TestAppQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() ep, keys := makeEpoch(rng) - vote := NewAppVote(NewAppProposal(0, 0, GenAppHash(rng), ep.EpochIndex())) + vote := NewAppVote(NewAppProposal(0, ep.RoadRange().First, GenAppHash(rng), ep.EpochIndex())) heavyOnly := NewAppQC([]*Signed[*AppVote]{ Sign(keys[0], vote), }) - require.NoError(t, heavyOnly.Verify(ep.Committee())) + require.NoError(t, heavyOnly.Verify(NewEpochDuo(ep, utils.None[*Epoch]()))) lightMajority := NewAppQC([]*Signed[*AppVote]{ Sign(keys[1], vote), Sign(keys[2], vote), }) - require.Error(t, lightMajority.Verify(ep.Committee())) + require.Error(t, lightMajority.Verify(NewEpochDuo(ep, utils.None[*Epoch]()))) } func TestTimeoutQCVerifyChecksEpochBinding(t *testing.T) { diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 1f6bfa37f4..c50488f3c1 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -23,16 +23,11 @@ type EpochDuo struct { // NewEpochDuo builds a Prev|Current window. // -// Requirements: current non-nil; when prev is present, non-nil and contiguous -// with current (index Current-1, Prev.Next == Current.First). Panics otherwise. +// Requirements: when prev is present, contiguous with current (index +// Current-1, Prev.Next == Current.First). Panics otherwise. Callers must pass +// non-nil Current (and non-nil Prev when present). func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { - if current == nil { - panic("NewEpochDuo: Current must be non-nil") - } if p, ok := prev.Get(); ok { - if p == nil { - panic("NewEpochDuo: Prev must be non-nil when present") - } if want := current.EpochIndex(); p.EpochIndex()+1 != want { panic(fmt.Sprintf("NewEpochDuo: Prev epoch %d not contiguous with Current %d", p.EpochIndex(), want)) diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 2c7ea686e5..f3d3bc6882 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -21,15 +21,6 @@ func testDuoEpochs(t *testing.T) (prev, current *types.Epoch) { return prev, current } -func TestNewEpochDuo_PanicsOnNilCurrent(t *testing.T) { - defer func() { - if recover() == nil { - t.Fatal("NewEpochDuo(nil, ...) should panic") - } - }() - _ = types.NewEpochDuo(nil, utils.None[*types.Epoch]()) -} - func TestNewEpochDuo_PanicsOnNonContiguousIndex(t *testing.T) { prev, _ := testDuoEpochs(t) rng := utils.TestRng() diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 39acc3c6fa..cf60a54506 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -508,16 +508,6 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } } else { app, _ := m.proposal.Msg().App().Get() - // 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) - } - 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") @@ -526,7 +516,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(appEp.Committee()); err != nil { + if err := appQC.Verify(vs.Epochs); err != nil { return fmt.Errorf("appQC: %w", err) } return nil diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index e68e750d2f..82729aa661 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -9,18 +9,18 @@ import ( type laneVoteSet struct { weight uint64 votes []*types.Signed[*types.LaneVote] - qc *types.LaneQC + qc utils.Option[*types.LaneQC] header *types.BlockHeader } func (s *laneVoteSet) reset() { s.weight = 0 s.votes = s.votes[:0] - s.qc = nil + s.qc = utils.None[*types.LaneQC]() } func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { - if s.qc != nil { + if s.qc.IsPresent() { return utils.None[*types.LaneQC]() } s.weight += weight @@ -28,8 +28,8 @@ func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneV if s.weight < quorum { return utils.None[*types.LaneQC]() } - s.qc = types.NewLaneQC(s.votes) - return utils.Some(s.qc) + s.qc = utils.Some(types.NewLaneQC(s.votes)) + return s.qc } // blockVotes weights votes under Current; reweight on epoch advance. @@ -45,12 +45,17 @@ func newBlockVotes() blockVotes { } } -// pushVote stores vote under ep; zero-weight signers stay in byKey for reweight. +// pushVote credits vote under ep. Zero-weight signers are not retained. +// Callers should VerifySig first; after a lock release, Weight==0 is a silent drop. 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]() } + w := ep.Committee().Weight(k) + if w == 0 { + return utils.None[*types.LaneQC]() + } bv.byKey[k] = vote h := vote.Msg().Header().Hash() @@ -59,14 +64,9 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot set = &laneVoteSet{header: vote.Msg().Header()} bv.byHash[h] = set } - w := ep.Committee().Weight(k) - if w == 0 { - return utils.None[*types.LaneQC]() - } return set.add(w, ep.Committee().LaneQuorum(), 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 { @@ -88,8 +88,8 @@ func (bv blockVotes) reweight(newEpoch *types.Epoch) bool { func (bv blockVotes) laneQC() utils.Option[*types.LaneQC] { for _, set := range bv.byHash { - if set.qc != nil { - return utils.Some(set.qc) + if set.qc.IsPresent() { + return 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 e75175495e..3211b4258f 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -28,11 +28,13 @@ func TestLaneVoteSet_Add(t *testing.T) { require.False(t, set.add(1, 2, mkVote()).IsPresent()) require.Equal(t, uint64(1), set.weight) require.Len(t, set.votes, 1) - require.Nil(t, set.qc) + require.False(t, set.qc.IsPresent()) qc, ok := set.add(1, 2, mkVote()).Get() require.True(t, ok) - require.Equal(t, set.qc, qc) + got, ok := set.qc.Get() + require.True(t, ok) + require.Equal(t, got, qc) require.Equal(t, uint64(2), set.weight) require.Len(t, set.votes, 2) @@ -44,10 +46,10 @@ func TestLaneVoteSet_Add(t *testing.T) { 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) + require.True(t, heavy.qc.IsPresent()) } -func TestPushVote_ZeroWeightKeptForReweight(t *testing.T) { +func TestPushVote_ZeroWeightNotRetained(t *testing.T) { rng := utils.TestRng() keyA := types.GenSecretKey(rng) keyZ := types.GenSecretKey(rng) @@ -59,21 +61,25 @@ func TestPushVote_ZeroWeightKeptForReweight(t *testing.T) { bv := newBlockVotes() 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) + require.NotContains(t, bv.byKey, keyZ.Public()) + require.Empty(t, bv.byHash) } -func TestPushVote_CurrentOnly(t *testing.T) { +func TestPushVote_CurrentCommitteeOnly(t *testing.T) { rng := utils.TestRng() keyA := types.GenSecretKey(rng) - keyE := types.GenSecretKey(rng) // next-only + keyB := types.GenSecretKey(rng) + keyC := types.GenSecretKey(rng) + keyD := types.GenSecretKey(rng) + keyE := types.GenSecretKey(rng) - ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{keyA.Public(): 1}) - ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{keyE.Public(): 1}) + // 4×1 → Faulty=1, LaneQuorum=2. + ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, + }) + ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, keyE.Public(): 1, + }) lane := keyA.Public() header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() @@ -81,24 +87,20 @@ func TestPushVote_CurrentOnly(t *testing.T) { bv := newBlockVotes() 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) + require.NotContains(t, bv.byKey, keyE.Public()) - qc, ok := bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).Get() + require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) + qc, ok := bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).Get() require.True(t, ok) - require.Equal(t, qc, bv.byHash[h].qc) - - got, ok := bv.laneQC().Get() + gotQC, ok := bv.byHash[h].qc.Get() require.True(t, ok) - require.Equal(t, qc, got) + require.Equal(t, qc, gotQC) - // After advance, E is counted under ep1. + // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; reweight keeps A+B → still quorum. 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") + require.False(t, bv.pushVote(ep1, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) + require.Contains(t, bv.byKey, keyE.Public()) + require.Equal(t, header, bv.byHash[h].header) } func TestPushVote_DedupsSigner(t *testing.T) { @@ -126,3 +128,32 @@ func TestPushVote_DedupsSigner(t *testing.T) { require.Equal(t, uint64(1), set.weight, "duplicate vote must not double-count") require.Len(t, set.votes, 1) } + +func TestPushVote_ReweightAfterAdvance(t *testing.T) { + rng := utils.TestRng() + keyA := types.GenSecretKey(rng) + keyB := types.GenSecretKey(rng) + keyC := types.GenSecretKey(rng) + keyD := types.GenSecretKey(rng) + + // 4×1 → LaneQuorum=2. + ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, + }) + // Only A remains → Faulty=0, LaneQuorum=1; reweight forms QC from A alone. + ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{keyA.Public(): 1}) + + lane := keyA.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + h := header.Hash() + + bv := newBlockVotes() + require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) + require.True(t, bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).IsPresent()) + + 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, keyA.Public(), bv.byHash[h].votes[0].Key()) + require.True(t, bv.byHash[h].qc.IsPresent()) +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 0908618ec2..9c7f03a9a7 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -233,14 +233,14 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error } // waitRoadInWindow blocks while roadIdx is ahead of the admitted window -// (too early / backpressure). Returns Some(epoch) when lookup admits the road, +// (too early / backpressure). Returns Some(duo) 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) { +) (utils.Option[types.EpochDuo], error) { duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { if lookup(duo).IsPresent() { return true @@ -248,13 +248,16 @@ func (s *State) waitRoadInWindow( return roadIdx < windowFirst(duo) }) if err != nil { - return utils.None[*types.Epoch](), err + return utils.None[types.EpochDuo](), err } - return lookup(duo), nil + if lookup(duo).IsPresent() { + return utils.Some(duo), nil + } + return utils.None[types.EpochDuo](), nil } // waitEpochForRoad: too early waits; behind window → None (stale). -func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { +func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[types.EpochDuo], error) { return s.waitRoadInWindow(ctx, roadIdx, func(duo types.EpochDuo) utils.Option[*types.Epoch] { return duo.EpochOptForRoad(roadIdx) }, types.EpochDuo.WindowFirst, @@ -263,7 +266,7 @@ func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) ( // waitCurrentForRoad blocks until roadIdx is in Current (too early); // None if behind Current (stale). Prev is not admitted — CommitQCs are Current-only. -func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[*types.Epoch], error) { +func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[types.EpochDuo], error) { 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 }, @@ -271,25 +274,24 @@ func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) } // admitRoadOrDrop waits for window admission. On stale it logs and returns -// (nil, nil) so Push* callers can drop without repeating the boilerplate. +// None 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) + wait func(context.Context, types.RoadIndex) (utils.Option[types.EpochDuo], error), +) (utils.Option[types.EpochDuo], error) { + duoOpt, err := wait(ctx, roadIdx) if err != nil { - return nil, err + return utils.None[types.EpochDuo](), err } - ep, ok := epOpt.Get() - if !ok { + if !duoOpt.IsPresent() { 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.EpochDuo](), nil } - return ep, nil + return duoOpt, nil } // waitPruneLeash blocks until latest AppQC is from epochIdx or later. @@ -402,10 +404,15 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - ep, err := s.admitRoadOrDrop(ctx, idx, "CommitQC", s.waitCurrentForRoad) - if err != nil || ep == nil { + duoOpt, err := s.admitRoadOrDrop(ctx, idx, "CommitQC", s.waitCurrentForRoad) + if err != nil { return err } + duo, ok := duoOpt.Get() + if !ok { + return nil + } + ep := duo.CurrentForRoad(idx).OrPanic("admitRoadOrDrop returned duo without Current road") if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } @@ -449,10 +456,15 @@ 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. - ep, err := s.admitRoadOrDrop(ctx, idx, "AppVote", s.waitEpochForRoad) - if err != nil || ep == nil { + duoOpt, err := s.admitRoadOrDrop(ctx, idx, "AppVote", s.waitEpochForRoad) + if err != nil { return err } + duo, ok := duoOpt.Get() + if !ok { + return nil + } + ep := duo.EpochOptForRoad(idx).OrPanic("admitRoadOrDrop returned duo without road") committee := ep.Committee() if err := v.VerifySig(committee); err != nil { return fmt.Errorf("v.VerifySig(): %w", err) @@ -509,11 +521,16 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ 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 { + duoOpt, err := s.admitRoadOrDrop(ctx, idx, "AppQC", s.waitEpochForRoad) + if err != nil { return err } - if err := appQC.Verify(ep.Committee()); err != nil { + duo, ok := duoOpt.Get() + if !ok { + return nil + } + ep := duo.EpochOptForRoad(idx).OrPanic("admitRoadOrDrop returned duo without road") + if err := appQC.Verify(duo); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) } if err := commitQC.Verify(ep); err != nil { @@ -645,10 +662,10 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos return nil } -// PushVote verifies off-lock against Current, then under lock credits with the -// live duo (drop if Current advanced and signer left). Does not wait for prior votes. +// PushVote verifies against Current, then under lock waits for lane capacity and +// credits with the live duo (drop if Current advanced and signer left). func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { - // Verify off-lock against a duo snapshot. + h := vote.Msg().Header() duo := s.epochDuo.Load() c := duo.Current.Committee() if err := vote.Msg().Verify(c); err != nil { @@ -658,7 +675,6 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return fmt.Errorf("vote.Verify(): %w", err) } verifiedEpoch := duo.Current.EpochIndex() - h := vote.Msg().Header() for inner, ctrl := range s.inner.Lock() { ls, ok := inner.lanes[h.Lane()] if !ok { @@ -673,7 +689,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote // WaitUntil may release the lock; re-check membership under live Current. live := inner.epochDuo.Load() if live.Current.EpochIndex() != verifiedEpoch && - !live.Current.Committee().HasReplica(vote.Key()) { + live.Current.Committee().Weight(vote.Key()) == 0 { return nil } if h.BlockNumber() < q.first { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 8067511702..183c487527 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -465,7 +465,7 @@ func TestWaitPruneLeash(t *testing.T) { // TestPushVote_DropsSignerAfterEpochAdvance: PushVote verifies off-lock, then // WaitUntil releases the lock; advanceEpoch installs a Current that excludes the -// signer — the vote must be dropped (no re-VerifySig; HasReplica only). +// signer — the vote must be dropped (Weight==0). func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx := t.Context() @@ -1093,13 +1093,13 @@ func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { registerDuoAtEpoch(state, 1) // Prev=0|Current=1 roadInPrev := types.RoadIndex(0) - epWin, err := state.waitEpochForRoad(t.Context(), roadInPrev) + duoWin, err := state.waitEpochForRoad(t.Context(), roadInPrev) require.NoError(t, err) - require.True(t, epWin.IsPresent(), "Prev|Current window still covers road 0") + require.True(t, duoWin.IsPresent(), "Prev|Current window still covers road 0") - epCur, err := state.waitCurrentForRoad(t.Context(), roadInPrev) + duoCur, 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") + require.False(t, duoCur.IsPresent(), "Current-only wait must treat Prev roads as too late") } func TestPushCommitQCStaleDrops(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index b8fb6be039..419bed99cc 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -451,7 +451,9 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty if err != nil { return err } - ep, err := s.epochDuo.Load().EpochForRoad(qc.QC().Proposal().Index()) + idx := qc.QC().Proposal().Index() + duo := s.epochDuo.Load() + ep, err := duo.EpochForRoad(idx) if err != nil { if !needQC && errors.Is(err, types.ErrRoadBeforeWindow) { return nil @@ -474,9 +476,7 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty } } // Closing Current: WaitForDuo(tipcut) before mutating nextQC. - idx := qc.QC().Proposal().Index() var nextDuo *types.EpochDuo - duo := s.epochDuo.Load() if needQC && duo.Current.RoadRange().IsLastRoad(idx) { nt, err := s.cfg.Registry.WaitForDuo(ctx, idx+1) if err != nil { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 28e8b5a0af..7e54e287ba 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -51,8 +51,11 @@ type registryState struct { // TODO(autobahn): replace genesis placeholders with epoch info on blocks. type Registry struct { state utils.RWMutex[*registryState] - // highestEpoch wakes WaitForDuo; monotonic, off registryState for EpochAt RLock. + // highestEpoch is the max registered index (informational). highestEpoch utils.AtomicSend[types.EpochIndex] + // epochGen bumps on every new registration; WaitForDuo waits on it so + // filling a gap below highest still wakes waiters. + epochGen utils.AtomicSend[uint64] } // NewRegistry creates a Registry with genesis epoch 0 only. @@ -69,6 +72,7 @@ func NewRegistry( latest: 0, }), highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), + epochGen: utils.NewAtomicSend(uint64(0)), }, nil } @@ -142,6 +146,7 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types if epochIdx > r.highestEpoch.Load() { r.highestEpoch.Store(epochIdx) } + r.epochGen.Store(r.epochGen.Load() + 1) return epoch } @@ -169,6 +174,7 @@ func (r *Registry) EnsureDuoAt(road types.RoadIndex) { } // AdvanceIfNeeded seeds epoch M+2 when roadIndex is LastRoad(M); else no-op. +// Also ensures M+1 so WaitForDuo(FirstRoad(M+2)) is not stuck on a Prev gap. // Call only after the last global of that road has executed (IsLastBlock). // // TODO(autobahn): pass the real M+2 committee once execution derives it. @@ -179,6 +185,7 @@ func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { if roadIndex != LastRoad(tipEpoch) { return } + r.EnsureEpoch(tipEpoch + 1) r.EnsureEpoch(tipEpoch + 2) } @@ -202,17 +209,20 @@ func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { return types.NewEpochDuo(current, prev), nil } -// WaitForDuo blocks until DuoAt(roadIndex) succeeds, then returns that duo. -// Must not hold the avail/data inner lock (execution may seed via AdvanceIfNeeded). +// WaitForDuo blocks until DuoAt(roadIndex) succeeds. +// Waits on epochGen (any registration), not only highestEpoch, so filling Prev +// after Current is already present still unblocks. Must not hold the avail/data +// inner lock (execution may seed via AdvanceIfNeeded). 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 := IndexForRoad(roadIndex) - if _, err := r.highestEpoch.Subscribe().Wait(ctx, func(highest types.EpochIndex) bool { - return highest >= centerIdx - }); err != nil { - return types.EpochDuo{}, err + sub := r.epochGen.Subscribe() + for { + // Capture gen before DuoAt so a registration between check and Wait still wakes. + seen := sub.Load() + if duo, err := r.DuoAt(roadIndex); err == nil { + return duo, nil + } + if _, err := sub.Wait(ctx, func(gen uint64) bool { return gen > seen }); err != nil { + return types.EpochDuo{}, err + } } - 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 0ac1c997eb..9f2afdf767 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -202,6 +202,7 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { latest: 0, }), highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), + epochGen: utils.NewAtomicSend(uint64(0)), } _, err := bare.DuoAt(FirstRoad(1)) if err == nil { @@ -222,6 +223,7 @@ func TestDuoAt_ErrorWhenPrevMissing(t *testing.T) { latest: 2, }), highestEpoch: utils.NewAtomicSend(types.EpochIndex(2)), + epochGen: utils.NewAtomicSend(uint64(0)), } _, err := bare.DuoAt(FirstRoad(2)) if err == nil { @@ -250,9 +252,35 @@ func TestWaitForDuo_FastPathAndWait(t *testing.T) { duo, err := r.WaitForDuo(t.Context(), tip) done <- result{duo, err} }() - r.EnsureEpoch(1) // Prev for DuoAt(epoch 2) - r.AdvanceIfNeeded(LastRoad(0)) // seeds epoch 2 + r.AdvanceIfNeeded(LastRoad(0)) // seeds 1 and 2 got := <-done require.NoError(t, got.err) require.Equal(t, types.EpochIndex(2), got.duo.Current.EpochIndex()) } + +// TestWaitForDuo_WakesWhenPrevFilledAfterCurrent: Current already registered +// without Prev; EnsureEpoch(Prev) must unblock WaitForDuo (epochGen, not only highest). +func TestWaitForDuo_WakesWhenPrevFilledAfterCurrent(t *testing.T) { + r, _ := makeRegistry(t) + r.EnsureEpoch(2) // gap: {0,2}, no 1 + tip := FirstRoad(2) + _, err := r.DuoAt(tip) + require.Error(t, err) + + type result struct { + duo types.EpochDuo + err error + } + done := make(chan result, 1) + go func() { + duo, err := r.WaitForDuo(t.Context(), tip) + done <- result{duo, err} + }() + r.EnsureEpoch(1) + got := <-done + require.NoError(t, got.err) + require.Equal(t, types.EpochIndex(2), got.duo.Current.EpochIndex()) + prev, ok := got.duo.Prev.Get() + require.True(t, ok) + require.Equal(t, types.EpochIndex(1), prev.EpochIndex()) +} From 5b23ec0900813e306d4842f2e1b5d2be5b0199c9 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 10:56:09 -0700 Subject: [PATCH 22/85] docs(autobahn): clarify Current-only lane vote credits (CON-358) Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/block_votes.go | 7 ++++--- sei-tendermint/internal/autobahn/avail/block_votes_test.go | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 82729aa661..6ce99e0074 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -32,7 +32,7 @@ func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneV return s.qc } -// blockVotes weights votes under Current; reweight on epoch advance. +// blockVotes credits lane votes under the Current committee only. type blockVotes struct { byKey map[types.PublicKey]*types.Signed[*types.LaneVote] byHash map[types.BlockHeaderHash]*laneVoteSet @@ -45,8 +45,8 @@ func newBlockVotes() blockVotes { } } -// pushVote credits vote under ep. Zero-weight signers are not retained. -// Callers should VerifySig first; after a lock release, Weight==0 is a silent drop. +// pushVote credits vote under ep (Current). Zero-weight → drop (not retained). +// Callers VerifySig first; after a lock release, Weight==0 is still a silent drop. 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 { @@ -67,6 +67,7 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot return set.add(w, ep.Committee().LaneQuorum(), vote) } +// reweight recomputes already-stored votes under new Current after advanceEpoch. func (bv blockVotes) reweight(newEpoch *types.Epoch) bool { c := newEpoch.Committee() for _, set := range bv.byHash { diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 3211b4258f..a782bd6b5a 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -96,7 +96,7 @@ func TestPushVote_CurrentCommitteeOnly(t *testing.T) { require.True(t, ok) require.Equal(t, qc, gotQC) - // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; reweight keeps A+B → still quorum. + // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; A+B still form quorum under new weights. require.True(t, bv.reweight(ep1)) require.False(t, bv.pushVote(ep1, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) require.Contains(t, bv.byKey, keyE.Public()) @@ -140,7 +140,7 @@ func TestPushVote_ReweightAfterAdvance(t *testing.T) { ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{ keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, }) - // Only A remains → Faulty=0, LaneQuorum=1; reweight forms QC from A alone. + // Only A remains → Faulty=0, LaneQuorum=1; A alone forms QC under new Current. ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{keyA.Public(): 1}) lane := keyA.Public() From 990ef87c03c800bda8d8929960271588e837c7b7 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 12:29:09 -0700 Subject: [PATCH 23/85] perf(autobahn): avoid QC re-fetch on execute for AdvanceIfNeeded (CON-358) Carry covering CommitQC road/last flags on GlobalBlock so executeBlock can seed epochs without a post-PushAppHash QC lookup that often hits BlockDB. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/block.go | 4 ++++ sei-tendermint/internal/autobahn/data/state.go | 12 +++++++----- sei-tendermint/internal/autobahn/data/state_test.go | 12 +++++++----- sei-tendermint/internal/p2p/giga/consensus_test.go | 2 ++ sei-tendermint/internal/p2p/giga_router_common.go | 12 ++++-------- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/sei-tendermint/autobahn/types/block.go b/sei-tendermint/autobahn/types/block.go index d3e283bd23..1cb8732a85 100644 --- a/sei-tendermint/autobahn/types/block.go +++ b/sei-tendermint/autobahn/types/block.go @@ -119,6 +119,10 @@ type GlobalBlock struct { Payload *Payload // Highest known finalized state. FinalAppState utils.Option[*AppProposal] + // Covering CommitQC road (from assembleGlobalBlock). + RoadIndex RoadIndex + // True when GlobalNumber is last in that QC's GlobalRange. + LastInCommitQC bool } // NewBlock creates a new Block. diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 419bed99cc..fd1f350ca3 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -641,11 +641,13 @@ func (s *State) TryBlock(n types.GlobalBlockNumber) (*types.Block, error) { func assembleGlobalBlock(n types.GlobalBlockNumber, b *types.Block, fqc *types.FullCommitQC) *types.GlobalBlock { qc := fqc.QC() return &types.GlobalBlock{ - GlobalNumber: n, - Timestamp: qc.Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), - Header: b.Header(), - Payload: b.Payload(), - FinalAppState: qc.Proposal().App(), + GlobalNumber: n, + Timestamp: qc.Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), + Header: b.Header(), + Payload: b.Payload(), + FinalAppState: qc.Proposal().App(), + RoadIndex: qc.Proposal().Index(), + LastInCommitQC: qc.GlobalRange().IsLastBlock(n), } } diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 9529b1a0f1..7f0fc8c676 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -147,11 +147,13 @@ func TestState(t *testing.T) { } wantG := &types.GlobalBlock{ - GlobalNumber: n, - Timestamp: want.QCs[n].QC().Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), - Header: wantB.Header(), - Payload: wantB.Payload(), - FinalAppState: want.QCs[n].QC().Proposal().App(), + GlobalNumber: n, + Timestamp: want.QCs[n].QC().Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), + Header: wantB.Header(), + Payload: wantB.Payload(), + FinalAppState: want.QCs[n].QC().Proposal().App(), + RoadIndex: want.QCs[n].QC().Proposal().Index(), + LastInCommitQC: want.QCs[n].QC().GlobalRange().IsLastBlock(n), } gotG, err := state.GlobalBlock(ctx, n) if err != nil { diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index 0f4de7cb08..f21d668953 100644 --- a/sei-tendermint/internal/p2p/giga/consensus_test.go +++ b/sei-tendermint/internal/p2p/giga/consensus_test.go @@ -60,6 +60,8 @@ func TestConsensusClientServer(t *testing.T) { return fmt.Errorf("ds.QC(): %w", err) } want.Timestamp = qc.QC().Proposal().BlockTimestamp(idx).OrPanic("global block not in QC") + want.RoadIndex = qc.QC().Proposal().Index() + want.LastInCommitQC = qc.QC().GlobalRange().IsLastBlock(idx) if err := utils.TestDiff(want, got); err != nil { return err } diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 4caee24e64..c2eae5a727 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -281,15 +281,11 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } // Seed N+2 when the last global of an epoch's closing road is executed. - // AdvanceIfNeeded owns LastRoad(epoch). Empty tipcuts are rejected by - // Proposal.Verify, so every closing road has a last global. + // Road/last flags come from GlobalBlock (assembled with the covering QC) + // so we do not re-fetch QC after PushAppHash may have evicted it from RAM. // 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) - } - if qc.QC().GlobalRange().IsLastBlock(b.GlobalNumber) { - r.data.Registry().AdvanceIfNeeded(qc.QC().Proposal().Index()) + if b.LastInCommitQC { + r.data.Registry().AdvanceIfNeeded(b.RoadIndex) } return commitResp, nil } From b24cd86463bcd5fde1aeebe4b8d66f22ad2363eb Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 12:31:11 -0700 Subject: [PATCH 24/85] fix(autobahn): pass real view EpochDuo into persisted validate (CON-358) Avoid NewEpochDuo(viewEp, None) so ViewSpec.Epochs matches DuoAt (Prev when Current > 0), which AppQC.Verify may consult. Co-authored-by: Cursor --- .../internal/autobahn/consensus/inner.go | 14 +++++--------- .../autobahn/consensus/persisted_inner.go | 16 +++++++++------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 805ac5b7e3..558332bebc 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -120,30 +120,26 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( persisted = *decoded } - // View epoch = tipcut; CommitQC may be prior epoch. Seeding is data's; + // View duo = tipcut; CommitQC may be prior epoch. Seeding is data's; // missing epoch hard-fails. Tip order: NewState / checkRestartTips. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) - viewEpoch, err := registry.EpochAt(nextViewRoad) + duo, err := registry.DuoAt(nextViewRoad) if err != nil { - return inner{}, fmt.Errorf("EpochAt(%d): %w", nextViewRoad, err) + return inner{}, fmt.Errorf("DuoAt(%d): %w", nextViewRoad, err) } - commitEpoch := viewEpoch + commitEpoch := duo.Current 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 { + if err := persisted.validate(commitEpoch, duo); err != nil { return inner{}, err } logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - duo, err := registry.DuoAt(nextViewRoad) - if err != nil { - return inner{}, fmt.Errorf("DuoAt(%d): %w", nextViewRoad, err) - } return inner{persistedInner: persisted, epochs: duo}, nil } diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 15fd0117e5..7ce7f294c8 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -73,12 +73,14 @@ type persistedInner struct { // Returns error on corrupt state. // // 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 { +// persisted CommitQC (used to verify the CommitQC itself). viewDuo is the +// Prev|Current window centered on the tipcut (NextIndexOpt(CommitQC)) — Current +// stamps currentView and verifies current-view artifacts; Prev is required when +// Current > 0 so ViewSpec.Epochs matches DuoAt (e.g. AppQC.Verify). +// When the CommitQC sits on the last road of an epoch, commitEp and +// viewDuo.Current differ; away from a boundary they are the same epoch. +func (p *persistedInner) validate(commitEp *types.Epoch, viewDuo types.EpochDuo) error { + viewEp := viewDuo.Current if cqc, ok := p.CommitQC.Get(); ok { if err := cqc.Verify(commitEp); err != nil { return fmt.Errorf("corrupt persisted state: CommitQC failed verification: %w", err) @@ -98,7 +100,7 @@ func (p *persistedInner) validate(commitEp, viewEp *types.Epoch) error { } } - vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epochs: types.NewEpochDuo(viewEp, utils.None[*types.Epoch]())} + vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epochs: viewDuo} currentView := vs.View() committee := viewEp.Committee() From 912edba071d16c9d47b7ad3de2d2c4360be8f8ec Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 12:35:38 -0700 Subject: [PATCH 25/85] refactor(autobahn): make blockVotes.reweight void (CON-358) The bool was unused in production; advanceEpoch callers already Updated(). Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/block_votes.go | 9 +++------ .../internal/autobahn/avail/block_votes_test.go | 5 +++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 6ce99e0074..dbbefb294f 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -68,23 +68,20 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot } // reweight recomputes already-stored votes under new Current after advanceEpoch. -func (bv blockVotes) reweight(newEpoch *types.Epoch) bool { +// Callers wake waiters via ctrl.Updated() after advanceEpoch (not via a return flag). +func (bv blockVotes) reweight(newEpoch *types.Epoch) { 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 - } + set.add(w, c.LaneQuorum(), vote) } - return quorumReached } func (bv blockVotes) laneQC() utils.Option[*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 a782bd6b5a..90d76c69bd 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -97,7 +97,8 @@ func TestPushVote_CurrentCommitteeOnly(t *testing.T) { require.Equal(t, qc, gotQC) // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; A+B still form quorum under new weights. - require.True(t, bv.reweight(ep1)) + bv.reweight(ep1) + require.True(t, bv.byHash[h].qc.IsPresent()) require.False(t, bv.pushVote(ep1, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) require.Contains(t, bv.byKey, keyE.Public()) require.Equal(t, header, bv.byHash[h].header) @@ -151,7 +152,7 @@ func TestPushVote_ReweightAfterAdvance(t *testing.T) { require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) require.True(t, bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).IsPresent()) - require.True(t, bv.reweight(ep1)) + bv.reweight(ep1) require.Equal(t, uint64(1), bv.byHash[h].weight) require.Len(t, bv.byHash[h].votes, 1) require.Equal(t, keyA.Public(), bv.byHash[h].votes[0].Key()) From c4209e113aba9e1c42d667f1db1df6367eeb3955 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 12:37:01 -0700 Subject: [PATCH 26/85] refactor(autobahn): drop unused Epoch return from fullCommitQC (CON-358) Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 16 ++++++++-------- .../internal/autobahn/avail/state_test.go | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 9c7f03a9a7..9982b9df98 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -741,29 +741,29 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc return headers, nil } -// fullCommitQC returns the FullCommitQC for road n and its signing epoch. +// fullCommitQC returns the FullCommitQC for road n. // ErrRoadBeforeWindow → ErrPruned (export may jump ahead). ErrRoadAfterWindow hard-fails. -func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, *types.Epoch, error) { +func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, error) { qc, err := s.CommitQC(ctx, n) if err != nil { - return nil, nil, err + return nil, err } ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { if errors.Is(err, types.ErrRoadBeforeWindow) { - return nil, nil, types.ErrPruned + return nil, types.ErrPruned } - return nil, nil, err + return nil, err } var commitHeaders []*types.BlockHeader for lane := range ep.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) if err != nil { - return nil, nil, err + return nil, err } commitHeaders = append(commitHeaders, headers...) } - return types.NewFullCommitQC(qc, commitHeaders), ep, nil + return types.NewFullCommitQC(qc, commitHeaders), nil } // WaitForLocalCapacity waits until the lane owned by this node has capacity for toProduce block. @@ -862,7 +862,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, err := s.fullCommitQC(ctx, n) if err != nil { if errors.Is(err, types.ErrPruned) { continue diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 183c487527..4b5b887e33 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -227,7 +227,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) } @@ -1147,7 +1147,7 @@ func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { // Slide duo so road 0 is behind WindowFirst. registerDuoAtEpoch(state, 2) - _, _, err = state.fullCommitQC(t.Context(), 0) + _, err = state.fullCommitQC(t.Context(), 0) require.ErrorIs(t, err, types.ErrPruned) } @@ -1181,7 +1181,7 @@ func TestFullCommitQCAfterWindowHardFails(t *testing.T) { } state.markCommitQCsPersisted(qc1) - _, _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(1)) + _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(1)) require.ErrorIs(t, err, types.ErrRoadAfterWindow) require.NotErrorIs(t, err, types.ErrPruned) } From 5f27858d68b052e4403f15e6271310bb7ef097e9 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 13:00:26 -0700 Subject: [PATCH 27/85] refactor(autobahn): drop highestEpoch; SetupInitialDuo returns error (CON-358) Clarify PushBlock vs PushVote verify, harden CommitQC span, and document boundary vote-drop recovery via view timeout. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 4 +- .../internal/autobahn/avail/state_test.go | 2 +- .../internal/autobahn/consensus/state.go | 3 +- .../internal/autobahn/consensus/state_test.go | 2 +- .../internal/autobahn/data/state.go | 7 +++- .../internal/autobahn/epoch/registry.go | 25 +++++------- .../internal/autobahn/epoch/registry_test.go | 40 +++++++++++-------- .../internal/autobahn/epoch/testonly.go | 2 +- 8 files changed, 47 insertions(+), 38 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 9982b9df98..88e77be93e 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -612,7 +612,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()) } - // Snapshot once so verify cannot race a boundary advanceEpoch (same as PushVote). + // Snapshot Current once for off-lock verify. Unlike PushVote, we do not + // re-check membership after WaitUntil — lane proposals are not reweighted + // across epoch advances (genesis committees are stable today). duo := s.epochDuo.Load() c := duo.Current.Committee() if err := p.Msg().Verify(c); err != nil { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 4b5b887e33..c406ceceda 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1536,7 +1536,7 @@ func TestRestartDuoFromClosingCommitQCSeedsNPlus1(t *testing.T) { registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} tip2 := epoch.FirstRoad(2) tip1 := epoch.LastRoad(1) - registry.SetupInitialDuo(utils.Some(types.RoadRange{First: tip1, Next: tip1 + 1})) + require.NoError(t, registry.SetupInitialDuo(utils.Some(types.RoadRange{First: tip1, Next: tip1 + 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/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index da81be3c52..0adc589fa9 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -190,7 +190,8 @@ func (s *State) PushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { func (s *State) PushPrepareVote(vote *types.Signed[*types.PrepareVote]) error { // 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. + // around an epoch boundary peers may disagree for a window; recovery is via + // view timeout (typically one timeout round per transition). i := s.innerRecv.Load() if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epochs.Current.EpochIndex() { logger.Debug("dropping prepare vote for non-current epoch", diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index d4406175b6..1481f67810 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -374,7 +374,7 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { } committee := utils.OrPanic1(types.NewCommittee(weights)) registry := utils.OrPanic1(epoch.NewRegistry(committee, 1, time.Time{})) - registry.SetupInitialDuo(utils.None[types.RoadRange]()) + require.NoError(t, registry.SetupInitialDuo(utils.None[types.RoadRange]())) registry.EnsureEpoch(1) keys := sks ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index fd1f350ca3..e4a50165c5 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -200,7 +200,9 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { if err != nil { return nil, fmt.Errorf("scan CommitQC span: %w", err) } - cfg.Registry.SetupInitialDuo(commitQCs) + if err := cfg.Registry.SetupInitialDuo(commitQCs); err != nil { + return nil, fmt.Errorf("SetupInitialDuo: %w", err) + } if err := s.loadFromBlockDB(blockDB); err != nil { return nil, fmt.Errorf("loadFromBlockDB: %w", err) @@ -238,6 +240,9 @@ func commitQCSpan(blockDB types.BlockDB) (utils.Option[types.RoadRange], error) if !ok { return utils.None[types.RoadRange](), fmt.Errorf("CommitQC span: first present but last missing") } + if last < first { + return utils.None[types.RoadRange](), fmt.Errorf("CommitQC span: last road %d < first %d", last, first) + } return utils.Some(types.RoadRange{First: first, Next: last + 1}), nil } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 7e54e287ba..4bf278ad12 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -51,10 +51,8 @@ type registryState struct { // TODO(autobahn): replace genesis placeholders with epoch info on blocks. type Registry struct { state utils.RWMutex[*registryState] - // highestEpoch is the max registered index (informational). - highestEpoch utils.AtomicSend[types.EpochIndex] // epochGen bumps on every new registration; WaitForDuo waits on it so - // filling a gap below highest still wakes waiters. + // filling a gap still wakes waiters. epochGen utils.AtomicSend[uint64] } @@ -71,8 +69,7 @@ func NewRegistry( m: map[types.EpochIndex]*types.Epoch{0: ep}, latest: 0, }), - highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), - epochGen: utils.NewAtomicSend(uint64(0)), + epochGen: utils.NewAtomicSend(uint64(0)), }, nil } @@ -82,11 +79,11 @@ func NewRegistry( // commitQCs is the half-open retained CommitQC range [First, Next). Seeds every // epoch covering [First, Next), EnsureDuoAt(Next), then placeholder // windowLast+1/+2 (see below). None = empty store → EnsureDuoAt(FirstRoad(1)) -// so {0,1}. Empty range (First >= Next) panics. -func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) { +// so {0,1}. Empty range (First >= Next) returns an error. +func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) error { if span, ok := commitQCs.Get(); ok { if span.First >= span.Next { - panic(fmt.Sprintf("SetupInitialDuo: empty CommitQC range [%d, %d)", span.First, span.Next)) + return fmt.Errorf("SetupInitialDuo: empty CommitQC range [%d, %d)", span.First, span.Next) } windowFirst := IndexForRoad(span.First) windowLast := IndexForRoad(span.Next - 1) @@ -106,10 +103,11 @@ func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) { // committees are linked to execution. r.EnsureEpoch(windowLast + 1) r.EnsureEpoch(windowLast + 2) - return + return nil } r.EnsureDuoAt(FirstRoad(1)) + return nil } // FirstBlock returns the first global block number of the genesis epoch. @@ -143,9 +141,6 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types 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 - if epochIdx > r.highestEpoch.Load() { - r.highestEpoch.Store(epochIdx) - } r.epochGen.Store(r.epochGen.Load() + 1) return epoch } @@ -210,9 +205,9 @@ func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { } // WaitForDuo blocks until DuoAt(roadIndex) succeeds. -// Waits on epochGen (any registration), not only highestEpoch, so filling Prev -// after Current is already present still unblocks. Must not hold the avail/data -// inner lock (execution may seed via AdvanceIfNeeded). +// Waits on epochGen (any registration), so filling Prev after Current is +// already present still unblocks. Must not hold the avail/data inner lock +// (execution may seed via AdvanceIfNeeded). func (r *Registry) WaitForDuo(ctx context.Context, roadIndex types.RoadIndex) (types.EpochDuo, error) { sub := r.epochGen.Subscribe() for { diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 9f2afdf767..ca1b881fd4 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -86,7 +86,9 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { func TestSetupInitialDuo_EmptyNoneSeedsGenesisNeighbor(t *testing.T) { r, _ := makeRegistry(t) - r.SetupInitialDuo(utils.None[types.RoadRange]()) + if err := r.SetupInitialDuo(utils.None[types.RoadRange]()); err != nil { + t.Fatal(err) + } for _, idx := range []types.EpochIndex{0, 1} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after empty seeding: %v", idx, err) @@ -97,21 +99,21 @@ func TestSetupInitialDuo_EmptyNoneSeedsGenesisNeighbor(t *testing.T) { } } -func TestSetupInitialDuo_EmptyRangePanics(t *testing.T) { +func TestSetupInitialDuo_EmptyRangeErrors(t *testing.T) { r, _ := makeRegistry(t) - defer func() { - if recover() == nil { - t.Fatal("empty CommitQC range must panic") - } - }() - r.SetupInitialDuo(utils.Some(types.RoadRange{First: 5, Next: 5})) + err := r.SetupInitialDuo(utils.Some(types.RoadRange{First: 5, Next: 5})) + if err == nil { + t.Fatal("empty CommitQC range must error") + } } func TestSetupInitialDuo_CommitQCMidSeedsPlaceholderNext(t *testing.T) { r, _ := makeRegistry(t) // Mid-5 CommitQC + tipcut EnsureDuoAt → {4,5}; always through windowLast+2 → 6,7. tip := midRoad(5) - r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + if err := r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})); err != nil { + t.Fatal(err) + } for _, idx := range []types.EpochIndex{4, 5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after CommitQC seeding: %v", idx, err) @@ -126,7 +128,9 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { r, _ := makeRegistry(t) // Closing tip: EnsureDuoAt(FirstRoad(6)) → {5,6}; windowLast+2 → 7. tip := LastRoad(5) - r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + if err := r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})); err != nil { + t.Fatal(err) + } for _, idx := range []types.EpochIndex{5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after closing CommitQC: %v", idx, err) @@ -143,10 +147,12 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { func TestSetupInitialDuo_CommitSpanFromFirst(t *testing.T) { r, _ := makeRegistry(t) // Span mid-2..mid-5 → seed epochs 2..5, then placeholder through windowLast+2 → 7. - r.SetupInitialDuo(utils.Some(types.RoadRange{ + if err := r.SetupInitialDuo(utils.Some(types.RoadRange{ First: midRoad(2), Next: midRoad(5) + 1, - })) + })); err != nil { + t.Fatal(err) + } for _, idx := range []types.EpochIndex{2, 3, 4, 5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after commit span seeding: %v", idx, err) @@ -177,7 +183,9 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { func TestDuoAt_MiddleEpoch(t *testing.T) { r, _ := makeRegistry(t) tip := midRoad(2) - r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + if err := r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})); err != nil { + t.Fatal(err) + } duo, err := r.DuoAt(FirstRoad(2)) if err != nil { t.Fatalf("DuoAt(epoch 2) error: %v", err) @@ -201,8 +209,7 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { m: map[types.EpochIndex]*types.Epoch{0: ep}, latest: 0, }), - highestEpoch: utils.NewAtomicSend(types.EpochIndex(0)), - epochGen: utils.NewAtomicSend(uint64(0)), + epochGen: utils.NewAtomicSend(uint64(0)), } _, err := bare.DuoAt(FirstRoad(1)) if err == nil { @@ -222,8 +229,7 @@ func TestDuoAt_ErrorWhenPrevMissing(t *testing.T) { m: map[types.EpochIndex]*types.Epoch{0: ep0, 2: ep2}, latest: 2, }), - highestEpoch: utils.NewAtomicSend(types.EpochIndex(2)), - epochGen: utils.NewAtomicSend(uint64(0)), + epochGen: utils.NewAtomicSend(uint64(0)), } _, err := bare.DuoAt(FirstRoad(2)) if err == nil { diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 41fe26bb88..dd7735fe31 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -55,7 +55,7 @@ func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Regis func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumber, startEpoch types.EpochIndex) *Registry { registry := utils.OrPanic1(NewRegistry(committee, firstBlock, time.Now())) - registry.SetupInitialDuo(utils.None[types.RoadRange]()) + utils.OrPanic(registry.SetupInitialDuo(utils.None[types.RoadRange]())) // Ensure at least {0,1} so DuoAt(FirstRoad(1)) works in tests. through := startEpoch if through < 1 { From cfaed53ffe76c238cf4e245a855ec4311e5ae30c Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 13:30:29 -0700 Subject: [PATCH 28/85] =?UTF-8?q?fix(autobahn):=20restart=20tip=20check=20?= =?UTF-8?q?uses=20avail=20=E2=89=A5=20data=20(CON-358)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consensus may lag BlockDB after async FullCommitQC export; catch-up from avail in Run is fine. Only avail behind data is corrupt. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 2 +- .../internal/autobahn/consensus/inner.go | 3 ++- .../internal/autobahn/consensus/state.go | 3 ++- .../internal/autobahn/data/state.go | 2 +- .../internal/autobahn/epoch/registry.go | 4 ++-- .../internal/p2p/giga_router_validator.go | 21 +++++++++++-------- .../p2p/giga_router_validator_test.go | 4 +++- 7 files changed, 23 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 88e77be93e..948ca6fca6 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -159,7 +159,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin } // DuoAt(CommitQC tipcut). Seeding is data.SetupInitialDuo; missing epoch hard-fails. - // Tip order: consensus.NewState (avail≥consensus), p2p.checkRestartTips (consensus≥data). + // Tip order: consensus.NewState (avail≥consensus), p2p.checkRestartTips (avail≥data). commitTip := types.RoadIndex(0) if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 558332bebc..cadd5da92f 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -121,7 +121,8 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( } // View duo = tipcut; CommitQC may be prior epoch. Seeding is data's; - // missing epoch hard-fails. Tip order: NewState / checkRestartTips. + // missing epoch hard-fails. Tip order: NewState (avail≥consensus); + // checkRestartTips (avail≥data; consensus may lag data and catch up). nextViewRoad := types.NextIndexOpt(persisted.CommitQC) duo, err := registry.DuoAt(nextViewRoad) if err != nil { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 0adc589fa9..71e935a460 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -136,7 +136,8 @@ func newState( myTimeoutQC: utils.NewAtomicSend(utils.None[*types.TimeoutQC]()), } // Avail admits CommitQCs before consensus tip catches up via LastCommitQC. - // Restart: avail tipcut must be >= consensus tipcut. + // Restart: avail tipcut must be >= consensus tipcut. Consensus may still + // lag data; Run() catch-up from avail closes that gap. if availTip, consTip := availState.CommitTipCut(), s.CommitTipCut(); availTip < consTip { return nil, fmt.Errorf("%w: avail tipcut %d < consensus tipcut %d", ErrAvailBehindConsensus, availTip, consTip) } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index e4a50165c5..5525d2bcf1 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -402,7 +402,7 @@ func (s *State) FirstAppProposal() types.GlobalBlockNumber { 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). +// none. Restart anchor for p2p.checkRestartTips (avail tip vs data). func (s *State) CommitTipCut() types.RoadIndex { for inner := range s.inner.Lock() { if inner.nextQC == 0 || inner.nextQC <= inner.first { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 4bf278ad12..fac34520d8 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -44,8 +44,8 @@ type registryState struct { // nothing. Finishing LastRoad(N-1) seeds epoch N+1 (AdvanceIfNeeded). // - data/ is the sole restart seeder (SetupInitialDuo). Avail/consensus must // not seed; tip into an unseeded epoch → EpochAt/DuoAt hard-fail. -// - Post-construction tipcuts: avail ≥ consensus ≥ data.CommitTipCut(); -// behind → hard-fail. +// - Post-construction tipcuts: avail ≥ consensus and avail ≥ data. +// Consensus may lag data (catch-up from avail in Run); avail < data → hard-fail. // - Placeholders use the genesis committee until real committees are wired. // // TODO(autobahn): replace genesis placeholders with epoch info on blocks. diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 506c1d8ba5..a93ef1613a 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -16,17 +16,20 @@ 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). +// ErrTipBehindData is returned on restart when avail's CommitQC tipcut is +// strictly behind data's (data QCs are exported from avail — reverse is corrupt). var ErrTipBehindData = errors.New("CommitQC tip behind data tip") -// checkRestartTips ensures consensus tipcut is not behind data. +// checkRestartTips ensures avail 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) +// inside consensus.NewState (avail ≥ consensus). +// +// Consensus may lag data: FullCommitQC can reach BlockDB before consensus's +// LastCommitQC catch-up persists. That is fine — Run()'s pushCommitQC advances +// consensus from avail. Only avail < data is insane. +func checkRestartTips(dataTip, availTip atypes.RoadIndex) error { + if availTip < dataTip { + return fmt.Errorf("%w: avail tipcut %d < data tipcut %d", ErrTipBehindData, availTip, dataTip) } return nil } @@ -53,7 +56,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey, dataSta if err != nil { return nil, fmt.Errorf("consensus.NewState(): %w", err) } - if err := checkRestartTips(dataState.CommitTipCut(), consensusState.CommitTipCut()); err != nil { + if err := checkRestartTips(dataState.CommitTipCut(), consensusState.Avail().CommitTipCut()); err != nil { return nil, err } producerState := producer.NewState(cfg.Producer, consensusState, cfg.App) diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index a2d1edf3d2..8785a97c98 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -355,7 +355,9 @@ func TestGigaRouter_EvmProxy(t *testing.T) { 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.NoError(t, checkRestartTips(1, 2), "avail lead is fine") + require.NoError(t, checkRestartTips(2, 2)) + // data ahead of avail is corrupt; consensus lagging data is OK (not checked here). require.ErrorIs(t, checkRestartTips(2, 1), ErrTipBehindData) require.ErrorIs(t, checkRestartTips(2, 0), ErrTipBehindData) } From 4d97d68739b571526f57598aec1219a77099760f Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 14:22:02 -0700 Subject: [PATCH 29/85] test(autobahn): make GenRegistry duo-only and rewrite epoch fixtures relative to M (CON-358) Seed only {M-1?, M} in test registries, use GenRegistryTip/EpochAtTip for leash and tipcut coverage, and drop redundant wait/restart stubs. Co-authored-by: Cursor --- .../internal/autobahn/avail/conv_test.go | 2 +- .../internal/autobahn/avail/inner_test.go | 63 +- .../internal/autobahn/avail/state_test.go | 557 ++++++++---------- .../internal/autobahn/consensus/inner_test.go | 23 +- .../autobahn/data/state_recovery_test.go | 38 +- .../internal/autobahn/data/state_test.go | 40 +- .../internal/autobahn/epoch/registry_test.go | 11 - .../internal/autobahn/epoch/testonly.go | 68 +-- sei-tendermint/internal/p2p/giga/data_test.go | 2 +- 9 files changed, 348 insertions(+), 456 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/conv_test.go b/sei-tendermint/internal/autobahn/avail/conv_test.go index e83e8b6a99..66b3dec3ff 100644 --- a/sei-tendermint/internal/autobahn/avail/conv_test.go +++ b/sei-tendermint/internal/autobahn/avail/conv_test.go @@ -19,7 +19,7 @@ func TestPruneAnchorConv(t *testing.T) { laneQCs := map[types.LaneID]*types.LaneQC{ lane: types.NewLaneQC(makeLaneVotes(keys, block.Header())), } - commitQC := makeCommitQC(registry.LatestEpoch(), keys, utils.None[*types.CommitQC](), laneQCs, utils.None[*types.AppQC]()) + commitQC := makeCommitQC(registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC](), laneQCs, utils.None[*types.AppQC]()) appProposal := types.NewAppProposal(commitQC.GlobalRange().First, commitQC.Proposal().Index(), types.GenAppHash(rng), commitQC.Proposal().EpochIndex()) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index b4cae3f97e..d3614a8552 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -121,10 +121,9 @@ func TestNewInnerLoadedNoAnchor(t *testing.T) { func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() - 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()) + registry, _, m := epoch.GenRegistryTip(rng, 4) + duo := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(m))) + require.Equal(t, m, duo.Current.EpochIndex()) _, err := newInner(registry, duo, utils.Some(&loadedAvailState{})) require.Error(t, err) @@ -260,7 +259,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -303,7 +302,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -355,7 +354,7 @@ func TestNewInnerLoadedAllThree(t *testing.T) { qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } // Pre-filtered: only commitQCs >= anchor road index (2). @@ -435,7 +434,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { h, )), } - qcs[j] = makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, utils.None[*types.AppQC]()) + qcs[j] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, laneQCs, utils.None[*types.AppQC]()) prev = utils.Some(qcs[j]) i.commitQCs.pushBack(qcs[j]) } @@ -473,7 +472,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -502,7 +501,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { qcs := make([]*types.CommitQC, 4) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -540,7 +539,7 @@ func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -588,7 +587,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { qcs := make([]*types.CommitQC, 11) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -633,7 +632,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -674,7 +673,7 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -784,7 +783,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -833,7 +832,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -928,43 +927,27 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { require.Contains(t, i.lanes, realLane, "active lane removed incorrectly") } -func TestAdvanceEpoch_EmptyQueuesNoop(t *testing.T) { - rng := utils.TestRng() - registry, _ := epoch.GenRegistryAt(rng, 4, 0) - duo := utils.OrPanic1(registry.DuoAt(0)) - - 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 - // keeps the current lane set intact. - i.advanceEpoch(duo) - for lane := range duo.Current.Committee().Lanes().All() { - require.Contains(t, i.lanes, lane) - } -} - func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { rng := utils.TestRng() + // Genesis duo needs no prune anchor; EnsureEpoch(1) gives the next duo. registry, _ := epoch.GenRegistryAt(rng, 4, 0) + registry.EnsureEpoch(1) - // duo0: Prev=nil, Current=epoch0 duo0 := utils.OrPanic1(registry.DuoAt(0)) i, err := newInner(registry, duo0, utils.None[*loadedAvailState]()) require.NoError(t, err) - // Collect a lane from epoch0 (will become Prev after advance). - var epoch0Lane types.LaneID + // Collect a lane from Current (will become Prev after advance). + var prevLane types.LaneID for l := range duo0.Current.Committee().Lanes().All() { - epoch0Lane = l + prevLane = l break } - require.Contains(t, i.lanes, epoch0Lane, "epoch0 lane missing before reweight") + require.Contains(t, i.lanes, prevLane, "Current lane missing before reweight") - // duo1: Prev=epoch0, Current=epoch1 duo1 := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(1))) 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") + // Prior Current lane is now in Prev — must be retained for boundary QC collection. + require.Contains(t, i.lanes, prevLane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index c406ceceda..f67502b2b6 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -194,7 +194,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { if err != nil { return fmt.Errorf("state.WaitForNewLaneQCs(): %w", err) } - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, state.LastAppQC()) + qc := makeCommitQC(registry.EpochAtTip(prev), keys, prev, laneQCs, state.LastAppQC()) if err := state.PushCommitQC(ctx, qc); err != nil { return fmt.Errorf("state.PushCommitQC(): %w", err) } @@ -315,7 +315,7 @@ func TestStateRestartFromPersisted(t *testing.T) { if err != nil { return fmt.Errorf("WaitForLaneQCs: %w", err) } - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, state.LastAppQC()) + qc := makeCommitQC(registry.EpochAtTip(prev), keys, prev, laneQCs, state.LastAppQC()) if err := state.PushCommitQC(ctx, qc); err != nil { return fmt.Errorf("PushCommitQC: %w", err) } @@ -623,7 +623,7 @@ func TestNewStateWithPersistence(t *testing.T) { prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC for i := types.RoadIndex(0); i <= roadIdx; i++ { - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qc := makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qc) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qc}, noCommitQCCB)) pruneQC = qc @@ -689,7 +689,7 @@ func TestNewStateWithPersistence(t *testing.T) { prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC for range roadIdx + 1 { - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qc := makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qc) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qc}, noCommitQCCB)) pruneQC = qc @@ -737,7 +737,7 @@ func TestNewStateWithPersistence(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -768,7 +768,7 @@ func TestNewStateWithPersistence(t *testing.T) { qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -796,7 +796,7 @@ func TestNewStateWithPersistence(t *testing.T) { allQCs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range allQCs { - allQCs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + allQCs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(allQCs[i]) } @@ -832,7 +832,7 @@ func TestNewStateWithPersistence(t *testing.T) { 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]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -878,7 +878,7 @@ func TestNewStateWithPersistence(t *testing.T) { cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -985,40 +985,43 @@ 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)) + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) ds := newTestDataState(&data.Config{Registry: registry}) 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]()) + parentTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + }) + qcPrev := makeCommitQC(epPrev, keys, utils.Some(parentTip), nil, utils.None[*types.AppQC]()) appQC := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) - // Move the operating window to Prev=1|Current=2 so road 0 is out of window. - registerDuoAtEpoch(state, 2) + // Slide Current to M+1 so M-1 falls behind WindowFirst. + registerDuoAtEpoch(state, m+1) - require.NoError(t, state.PushAppQC(t.Context(), appQC, qc0)) + require.NoError(t, state.PushAppQC(t.Context(), appQC, qcPrev)) require.False(t, state.LastAppQC().IsPresent()) } func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) // {0,1}; tip starts at road 0 + registry, keys, m := epoch.GenRegistryTip(rng, 4) ds := newTestDataState(&data.Config{Registry: registry}) 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.FirstRoad(1))) + // AppVote for Current's first road while CommitQC tip is still behind: + // PushAppVote blocks in waitForCommitQC (not on the epoch window). + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) proposal := types.NewAppProposal( - ep1.FirstBlock(), ep1.RoadRange().First, types.GenAppHash(rng), ep1.EpochIndex()) + epM.FirstBlock(), epM.RoadRange().First, types.GenAppHash(rng), epM.EpochIndex()) vote := types.Sign(keys[0], types.NewAppVote(proposal)) ctx, cancel := context.WithCancel(t.Context()) cancel() @@ -1026,76 +1029,22 @@ 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 := newTestDataState(&data.Config{Registry: registry}) - state, err := NewState(keys[0], ds, utils.None[string]()) - require.NoError(t, err) - - ctx, cancel := context.WithCancel(t.Context()) - cancel() - _, err = state.waitEpochForRoad(ctx, epoch.FirstRoad(2)) - require.ErrorIs(t, err, context.Canceled) -} - -func TestWaitEpochForRoadStaleReturnsNone(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) - ds := newTestDataState(&data.Config{Registry: registry}) - state, err := NewState(keys[0], ds, utils.None[string]()) - require.NoError(t, err) - - registerDuoAtEpoch(state, 2) - - ep, err := state.waitEpochForRoad(t.Context(), 0) - require.NoError(t, err) - require.False(t, ep.IsPresent()) -} - -func TestWaitCurrentForRoadFutureBlocks(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) - ds := newTestDataState(&data.Config{Registry: registry}) - 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 := newTestDataState(&data.Config{Registry: registry}) - 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) + registry, keys, m := epoch.GenRegistryTip(rng, 4) ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - registerDuoAtEpoch(state, 1) // Prev=0|Current=1 + registerDuoAtEpoch(state, m) // Prev=M-1|Current=M - roadInPrev := types.RoadIndex(0) + roadInPrev := epoch.FirstRoad(m - 1) duoWin, err := state.waitEpochForRoad(t.Context(), roadInPrev) require.NoError(t, err) - require.True(t, duoWin.IsPresent(), "Prev|Current window still covers road 0") + require.True(t, duoWin.IsPresent(), "Prev|Current window still covers Prev roads") duoCur, err := state.waitCurrentForRoad(t.Context(), roadInPrev) require.NoError(t, err) @@ -1104,23 +1053,29 @@ func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { func TestPushCommitQCStaleDrops(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) - ep0 := utils.OrPanic1(registry.EpochAt(0)) + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) ds := newTestDataState(&data.Config{Registry: registry}) 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]()) + parentTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + }) + qcStale := makeCommitQC(epPrev, keys, utils.Some(parentTip), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(m-1), qcStale.Proposal().Index()) - registerDuoAtEpoch(state, 2) + state.markCommitQCsPersisted(parentTip) + registerDuoAtEpoch(state, m+1) - require.NoError(t, state.PushCommitQC(t.Context(), qc0)) - require.False(t, state.LastCommitQC().Load().IsPresent(), "stale CommitQC must not be admitted") + tipBefore := state.LastCommitQC().Load() + require.NoError(t, state.PushCommitQC(t.Context(), qcStale)) + require.Equal(t, tipBefore, state.LastCommitQC().Load(), "stale CommitQC must not advance tip") } // TestFullCommitQCBeforeWindowIsPruned: CommitQC still held but duo has moved @@ -1129,25 +1084,35 @@ func TestPushCommitQCStaleDrops(t *testing.T) { // 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) - ep0 := utils.OrPanic1(registry.EpochAt(0)) + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) + registry.EnsureEpoch(m + 2) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) ds := newTestDataState(&data.Config{Registry: registry}) 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) + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + }) + qc := makeCommitQC(epM, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(m), qc.Proposal().Index()) - // Slide duo so road 0 is behind WindowFirst. - registerDuoAtEpoch(state, 2) + // Plant an admitted QC, then slide the duo past it (skip live PushCommitQC). + for inner := range state.inner.Lock() { + inner.commitQCs.first = epoch.FirstRoad(m) + inner.commitQCs.next = epoch.FirstRoad(m) + 1 + inner.commitQCs.q[epoch.FirstRoad(m)] = qc + } + state.markCommitQCsPersisted(qc) + registerDuoAtEpoch(state, m+2) - _, err = state.fullCommitQC(t.Context(), 0) + _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(m)) require.ErrorIs(t, err, types.ErrPruned) } @@ -1155,122 +1120,113 @@ func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { // 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))) + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - // Plant a CommitQC for epoch 1 while duo is still epoch 0. + // Operating window still on Prev; plant a CommitQC for Current's first road. + registerDuoAtEpoch(state, m-1) + 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)}))), + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), }) - 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()) + qc1 := makeCommitQC(epM, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(m), 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 + inner.commitQCs.first = epoch.FirstRoad(m) + inner.commitQCs.next = epoch.FirstRoad(m) + 1 + inner.commitQCs.q[epoch.FirstRoad(m)] = qc1 } state.markCommitQCsPersisted(qc1) - _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(1)) + _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(m)) require.ErrorIs(t, err, types.ErrRoadAfterWindow) require.NotErrorIs(t, err, types.ErrPruned) } -// TestPushCommitQCMidEpochNoExecLeash: mid-N CommitQC admits without registry -// N+1 (execution may still be in N-1). Only sealing N waits on N+1. +// TestPushCommitQCMidEpochNoExecLeash: mid-M CommitQC admits without registry +// M+1 (execution may still be in M-1). Only sealing M waits on M+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)) - 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") + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "test setup: M+1 must be absent") ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - registerDuoAtEpoch(state, 1) + registerDuoAtEpoch(state, m) - lane := keys[0].Public() prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ - EpochIndex: 0, - Index: epoch.LastRoad(0), + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), }))), }) - 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()) + qc1 := makeCommitQC(epM, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(m), qc1.Proposal().Index()) for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.FirstRoad(1) - inner.commitQCs.next = epoch.FirstRoad(1) + inner.commitQCs.first = epoch.FirstRoad(m) + inner.commitQCs.next = epoch.FirstRoad(m) } state.markCommitQCsPersisted(prevTip) 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.Equal(t, epoch.FirstRoad(m)+1, inner.commitQCs.next) } } -// TestPushCommitQCWaitsForEpochUnlock: sealing N without registry N+1 waits. +// TestPushCommitQCWaitsForEpochUnlock: sealing M without registry M+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)) - 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") + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "test setup: M+1 must be absent") ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - registerDuoAtEpoch(state, 1) + registerDuoAtEpoch(state, m) - lane := keys[0].Public() - // 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), + // Seal needs AppQC in M as well as registry M+1. + qcMid := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), }))), - })), map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockMid.Header()))}, - utils.None[*types.AppQC]()) - appQC1 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qcMid.GlobalRange().First, qcMid.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcMid.GlobalRange().First, qcMid.Index(), types.GenAppHash(rng), epM.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, + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 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()) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQC1) - inner.commitQCs.first = epoch.FirstRoad(1) - inner.commitQCs.next = epoch.LastRoad(1) + inner.latestAppQC = utils.Some(appQCM) + inner.commitQCs.first = epoch.FirstRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) @@ -1278,101 +1234,95 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { cancel() require.ErrorIs(t, state.PushCommitQC(ctx, qcLast), context.Canceled) - registry.AdvanceIfNeeded(epoch.LastRoad(0)) // seeds epoch 2 + registry.EnsureEpoch(m + 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()) + require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + require.Equal(t, m+1, inner.epochDuo.Load().Current.EpochIndex()) } } -// TestPushAppQCWaitsForEpochUnlock: tipcut seal of N waits on registry N+1. +// TestPushAppQCWaitsForEpochUnlock: tipcut seal of M waits on registry M+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") + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "test setup: M+1 must be absent") ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - registerDuoAtEpoch(state, 1) + registerDuoAtEpoch(state, m) - 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, + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 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]()) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, 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()) + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.LastRoad(1) - inner.commitQCs.next = epoch.LastRoad(1) + inner.commitQCs.first = epoch.LastRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) ctx, cancel := context.WithCancel(t.Context()) cancel() - // Incoming AppQC satisfies seal AppQC leash; still waits on missing epoch 2. + // Incoming AppQC satisfies seal AppQC leash; still waits on missing M+1. require.ErrorIs(t, state.PushAppQC(ctx, appQCLast, qcLast), context.Canceled) - registry.AdvanceIfNeeded(epoch.LastRoad(0)) + registry.EnsureEpoch(m + 1) 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()) + require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + require.Equal(t, m+1, inner.epochDuo.Load().Current.EpochIndex()) } } -// TestPushCommitQCBoundaryWaitsForAppQCInEpoch: last CommitQC of epoch E drops -// Prev (E-1); require AppQC in E so E-1 is pruned before the window slides. +// TestPushCommitQCBoundaryWaitsForAppQCInEpoch: last CommitQC of epoch M drops +// Prev (M-1); require AppQC in M so M-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))) + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) // exec leash for sealing M + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) ds := newTestDataState(&data.Config{Registry: registry}) 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 + registerDuoAtEpoch(state, m) - 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()))) + qcPrev := makeCommitQC(epPrev, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + })), nil, utils.None[*types.AppQC]()) + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.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, + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 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()) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(m), 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) + inner.latestAppQC = utils.Some(appQCPrev) // only M-1 — not enough to close M + inner.commitQCs.first = epoch.LastRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) @@ -1380,171 +1330,140 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { 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), + // AppQC in epoch M unlocks the boundary. + qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), }))), - })), 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()))) + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.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) + inner.latestAppQC = utils.Some(appQCM) + inner.commitQCs.first = epoch.FirstRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) } 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()) + require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + require.Equal(t, m+1, 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. +// TestPushAppQCBoundaryIncomingAppQC: tipcut closing M may carry the first +// AppQC in M; 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))) + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - registerDuoAtEpoch(state, 1) - registry.AdvanceIfNeeded(epoch.LastRoad(0)) + registerDuoAtEpoch(state, m) - 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()))) + qcPrev := makeCommitQC(epPrev, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + })), nil, utils.None[*types.AppQC]()) + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.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, + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 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]()) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.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) + inner.latestAppQC = utils.Some(appQCPrev) // stale; incoming is appQCLast + inner.commitQCs.first = epoch.LastRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) } 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()) + require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + require.Equal(t, m+1, 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)) - ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) ds := newTestDataState(&data.Config{Registry: registry}) 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. + registerDuoAtEpoch(state, m-1) + + // Satisfy waitForCommitQC(FirstRoad(m)-1) without pushing EpochLength QCs. + // Current remains M-1, so FirstRoad(m) is too early for waitCurrentForRoad. tipQC := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ - EpochIndex: 0, - Index: epoch.LastRoad(0), + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), }))), }) 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), + qcM := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.FirstRoad(m), }))), }) ctx, cancel := context.WithCancel(t.Context()) cancel() - require.ErrorIs(t, state.PushCommitQC(ctx, qc1), context.Canceled) + require.ErrorIs(t, state.PushCommitQC(ctx, qcM), 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). +// Prev is accepted after Current has advanced. Its committee is resolved from +// Prev, not Current. func TestPushAppQCPreviousEpoch(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 3, 0) // {0,1} - epochN1 := utils.OrPanic1(registry.EpochAt(0)) // epoch 0 (N-1) + registry, keys, m := epoch.GenRegistryTip(rng, 3) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 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]()) + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + }) + commitQC := makeCommitQC(epPrev, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) gr := commitQC.GlobalRange() - appProposal := types.NewAppProposal(gr.First, commitQC.Index(), types.GenAppHash(rng), epochN1.EpochIndex()) + appProposal := types.NewAppProposal(gr.First, commitQC.Index(), types.GenAppHash(rng), epPrev.EpochIndex()) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - // Force the operating window to Prev=0|Current=1 (as after a boundary CommitQC). - registerDuoAtEpoch(state, 1) + registerDuoAtEpoch(state, m) require.NoError(t, state.PushAppQC(t.Context(), appQC, commitQC), - "late AppQC from epoch N-1 should be accepted after Current advanced to N") + "late AppQC from Prev should be accepted after Current advanced") require.True(t, state.LastAppQC().IsPresent()) } -// TestRestartDuoFromCommitTipNeedsSetup: DuoAt at FirstRoad(2) needs epoch 2. -// GenRegistryAt(…,0) only has {0,1}; lead tips do not soft-seed. -func TestRestartDuoFromCommitTipNeedsSetup(t *testing.T) { - rng := utils.TestRng() - registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} - tip2 := epoch.FirstRoad(2) - _, err := registry.DuoAt(tip2) - require.Error(t, err, "DuoAt(epoch-2 tip) must fail without epoch 2") -} - -// TestRestartDuoFromClosingCommitQCSeedsNPlus1: closing CommitQC in epoch 1 -// seeds DuoAt(FirstRoad(2)) via SetupInitialDuo; newInner still needs a prune -// anchor when Current > 0. -func TestRestartDuoFromClosingCommitQCSeedsNPlus1(t *testing.T) { - rng := utils.TestRng() - registry, _ := epoch.GenRegistryAt(rng, 3, 0) // {0,1} - tip2 := epoch.FirstRoad(2) - tip1 := epoch.LastRoad(1) - require.NoError(t, registry.SetupInitialDuo(utils.Some(types.RoadRange{First: tip1, Next: tip1 + 1}))) - tipDuo2, err := registry.DuoAt(tip2) - require.NoError(t, err) - require.Equal(t, types.EpochIndex(2), tipDuo2.Current.EpochIndex()) - - _, err = newInner(registry, tipDuo2, utils.None[*loadedAvailState]()) - require.Error(t, err, "epoch > 0 requires a prune anchor") -} - func TestNextCommitQC(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 4) @@ -1553,7 +1472,7 @@ func TestNextCommitQC(t *testing.T) { 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]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } require.Equal(t, types.RoadIndex(3), (&loadedAvailState{ diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 8307e34a31..4562337fa5 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -72,19 +72,18 @@ func TestNewInnerEmpty(t *testing.T) { require.False(t, i.TimeoutVote.IsPresent(), "timeoutVote should be None") } -// TestNewInner_ConsensusTipLeadsDataWindow: data CommitQC tip is in epoch 1; -// registry has epoch 2 (live AdvanceIfNeeded lookahead). Persisted CommitQC is -// LastRoad(1) → view tipcut FirstRoad(2). +// TestNewInner_ConsensusTipLeadsDataWindow: data CommitQC tip is in epoch M; +// registry has M+1 (live AdvanceIfNeeded lookahead). Persisted CommitQC is +// LastRoad(M) → view tipcut FirstRoad(M+1). func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { rng := utils.TestRng() - registry, keys, _ := epoch.GenRegistry(rng, 3) // {0,1} only - ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) - closing := epoch.LastRoad(1) - tipcut := epoch.FirstRoad(2) + registry, keys, m := epoch.GenRegistryTip(rng, 3) + registry.EnsureEpoch(m + 1) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + closing := epoch.LastRoad(m) + tipcut := epoch.FirstRoad(m + 1) - // Live path after finishing LastRoad(0): seed epoch 2. - registry.EnsureEpoch(2) - vote := types.NewCommitVote(types.ProposalAt(ep1, types.View{Index: closing})) + vote := types.NewCommitVote(types.ProposalAt(epM, types.View{Index: closing})) votes := make([]*types.Signed[*types.CommitVote], len(keys)) for i, k := range keys { votes[i] = types.Sign(k, vote) @@ -95,11 +94,11 @@ func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { CommitQC: utils.Some(cqc), })), registry) require.NoError(t, err) - require.Equal(t, types.EpochIndex(2), i.epochs.Current.EpochIndex()) + require.Equal(t, m+1, i.epochs.Current.EpochIndex()) require.Equal(t, tipcut, i.View().Index) prev, ok := i.epochs.Prev.Get() require.True(t, ok) - require.Equal(t, types.EpochIndex(1), prev.EpochIndex()) + require.Equal(t, m, prev.EpochIndex()) } func TestNewInnerPrepareVote(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index 9d08f61655..8fae14822c 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -46,7 +46,7 @@ func TestNewStateInMemoryMode(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) state := utils.OrPanic1(NewState(&Config{Registry: registry}, memblock.NewBlockDB())) @@ -73,8 +73,8 @@ func TestRecoveryNormal(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -250,9 +250,9 @@ func TestPruningDiscards(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - qc3, blocks3 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc2.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) + qc3, blocks3 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc2.QC())), keys, utils.Some(qc2.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() gr3 := qc3.QC().GlobalRange() @@ -286,9 +286,9 @@ func TestRecoveryAfterPruning(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - qc3, blocks3 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc2.QC())) + qc1, _ := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) + qc3, blocks3 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc2.QC())), keys, utils.Some(qc2.QC())) gr2 := qc2.QC().GlobalRange() gr3 := qc3.QC().GlobalRange() @@ -333,8 +333,8 @@ func TestRecoveryBlocksBehind(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -381,7 +381,7 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() if gr1.Next-gr1.First < 3 { t.Skip("need at least 3 blocks in QC range to test split") @@ -433,8 +433,8 @@ func TestRecoveryAfterPruneNoGC(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -477,7 +477,7 @@ func TestRecoveryQCsNoBlocks(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, _ := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() db1 := newTestBlockDB(t, dir) @@ -509,8 +509,8 @@ func TestRunPersistSeedsFromRecoveryFloor(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, _ := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() require.Greater(t, gr2.First, registry.FirstBlock(), "need skipTo past genesis") @@ -561,7 +561,7 @@ func TestRecoveryBlockGap(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() // TestCommitQC generates 10 global blocks, so the range is always wide @@ -599,7 +599,7 @@ func TestRecoveryBlockGap(t *testing.T) { func TestNewState_AppLeadsBlockDBFlush(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() db := memblock.NewBlockDB() diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 7f0fc8c676..d0495920d1 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -115,7 +115,7 @@ func TestState(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, prev) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(prev), keys, prev) prev = utils.Some(qc.QC()) if err := state.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("state.PushQC(): %w", err) @@ -183,7 +183,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push a valid QC to advance inner.nextQC. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, blocks1)) gr1 := qc1.QC().GlobalRange() @@ -223,7 +223,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { malBlocks = append(malBlocks, b) } } - viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epochs: types.NewEpochDuo(registry.LatestEpoch(), utils.None[*types.Epoch]())} + viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epochs: types.NewEpochDuo(registry.EpochAtTip(utils.None[*types.CommitQC]()), utils.None[*types.Epoch]())} leader := committee.Leader(viewSpec.View()) var leaderKey types.SecretKey for _, k := range keys { @@ -273,7 +273,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { } // Verify state is still functional: the next valid QC is accepted and visible. - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) require.NoError(t, state.PushQC(ctx, qc2, blocks2)) gr2 := qc2.QC().GlobalRange() for n := gr2.First; n < gr2.Next; n++ { @@ -290,7 +290,7 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push qc1 with NO blocks — only the QC is stored. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, nil)) gr := qc1.QC().GlobalRange() @@ -339,7 +339,7 @@ func TestExecution(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, prev) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(prev), keys, prev) if err := state.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("state.PushQC(): %w", err) } @@ -376,7 +376,7 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push QC without blocks. - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, nil)) gr := qc.QC().GlobalRange() @@ -394,7 +394,7 @@ func TestGlobalBlockByHash(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, blocks)) gr := qc.QC().GlobalRange() n := gr.First @@ -434,7 +434,7 @@ func TestPushQCBeforeRunPersistsToBlockDB(t *testing.T) { registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() db := newTestBlockDB(t, dir) @@ -485,11 +485,11 @@ func TestEvictionWaitsForCommitQCApp(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() require.False(t, qc1.QC().Proposal().App().IsPresent(), "genesis CommitQC has no App") - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) app, ok := qc2.QC().Proposal().App().Get() require.True(t, ok, "second CommitQC embeds App for qc1 tip") appFloor := app.GlobalNumber() @@ -555,9 +555,9 @@ func TestNextToExecuteAfterAppEviction(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) app, ok := qc2.QC().Proposal().App().Get() require.True(t, ok) require.Equal(t, gr1.Next-1, app.GlobalNumber()) @@ -619,7 +619,7 @@ func TestPruningKeepsLastQCRange(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() state1 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -678,8 +678,8 @@ func TestPruningWithPartialQCRange(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() app, ok := qc2.QC().Proposal().App().Get() @@ -763,11 +763,11 @@ func TestPushBlockWaitsForQC(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push first QC covering [0, N). - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, blocks1)) // Prepare second QC covering [N, M) but don't push it yet. - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() // Block gr2.First should not be in state yet. @@ -810,8 +810,8 @@ func TestTryBlockHidesGapFills(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() require.GreaterOrEqual(t, gr2.Len(), 2) diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index ca1b881fd4..7ff9124c60 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -37,17 +37,6 @@ func TestNewRegistry_GenesisEpochBoundedRange(t *testing.T) { } } -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 TestEpochAt_WithinGenesisEpoch(t *testing.T) { r, _ := makeRegistry(t) ep, err := r.EpochAt(LastRoad(0)) diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index dd7735fe31..4f2c18a379 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -8,6 +8,9 @@ import ( ) // LatestEpoch returns the most recently activated epoch. For use in tests only. +// Do not use this to stamp CommitQC views for an arbitrary road — use +// EpochAtTip (or EpochAt) so View.EpochIndex matches the road's epoch when +// GenRegistry starts away from genesis. func (r *Registry) LatestEpoch() *types.Epoch { for s := range r.state.RLock() { return s.m[s.latest] @@ -15,32 +18,31 @@ func (r *Registry) LatestEpoch() *types.Epoch { 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]. -// Returns the registry, secret keys, and the starting epoch index. +// EpochAtTip is the epoch for the next CommitQC after prev (road 0 if none). +// Intended for test CommitQC chains when GenRegistry's start epoch may be ≫ 0. +func (r *Registry) EpochAtTip(prev utils.Option[*types.CommitQC]) *types.Epoch { + return utils.OrPanic1(r.EpochAt(types.NextIndexOpt(prev))) +} + +// GenRegistry generates a random Registry of the given committee size, starting +// at a random epoch N ∈ [0, 100]. Seeds only the duo at N ({N−1 if N>0, N}), +// plus genesis 0 from NewRegistry — not M+1/M+2 placeholders and not a dense +// 0..N fill. startEpoch is drawn from rng.Split() so it does not depend on how +// many draws committee construction consumes. +// Callers building CommitQC chains must use EpochAtTip / EpochAt(road), not +// LatestEpoch(), for View.EpochIndex. Tests that need N+1/N+2 must EnsureEpoch +// (or AdvanceIfNeeded) themselves. +// Returns the registry, secret keys, and N. // 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 is a global height. Keep 0 so empty-store tipcuts stay on - // road indices that only need epochs {0,1} — matching production genesis. - const firstBlock types.GlobalBlockNumber = 0 - // 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 FirstRoad(startEpoch). - startEpoch := types.EpochIndex(rng.Intn(2)) //nolint:gosec - r := makeRegistryAt(committee, firstBlock, startEpoch) + startEpoch := types.EpochIndex(rng.Split().Intn(101)) //nolint:gosec + r, sks := GenRegistryAt(rng, size, startEpoch) return r, sks, startEpoch } -// GenRegistryAt generates a Registry of the given committee size centered on startEpoch. -// Seeds [startEpoch-1, startEpoch] so DuoAt(FirstRoad(startEpoch)) works. +// GenRegistryAt generates a Registry of the given committee size centered on +// startEpoch. Seeds only {startEpoch−1 (if >0), startEpoch} via EnsureDuoAt — +// not startEpoch+1/+2 (callers add those when needed). // 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) @@ -53,21 +55,21 @@ func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Regis return makeRegistryAt(committee, firstBlock, startEpoch), sks } +// GenRegistryTip is GenRegistryAt on a random M ∈ [1, 100] so Prev is always +// present ({M−1, M} only). Prefer this over GenRegistryAt(..., 0) for tests that +// need a non-genesis Current. +func GenRegistryTip(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.EpochIndex) { + m := types.EpochIndex(1 + rng.Split().Intn(100)) //nolint:gosec + r, sks := GenRegistryAt(rng, size, m) + return r, sks, m +} + func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumber, startEpoch types.EpochIndex) *Registry { registry := utils.OrPanic1(NewRegistry(committee, firstBlock, time.Now())) - utils.OrPanic(registry.SetupInitialDuo(utils.None[types.RoadRange]())) - // Ensure at least {0,1} so DuoAt(FirstRoad(1)) works in tests. - through := startEpoch - if through < 1 { - through = 1 - } + // Duo at startEpoch only; no placeholder +1/+2 (unlike SetupInitialDuo's + // CommitQC-span path). Genesis 0 always exists from NewRegistry. + registry.EnsureDuoAt(FirstRoad(startEpoch)) for s := range registry.state.Lock() { - for idx := types.EpochIndex(0); idx <= through; idx++ { - if _, ok := s.m[idx]; ok { - continue - } - registry.makeEpoch(s, idx) - } s.latest = startEpoch } return registry diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 48d1fa8df4..c9b9d97e2c 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -109,7 +109,7 @@ func TestDataClientServer(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := data.TestCommitQC(rng, server.data.Registry().LatestEpoch(), keys, prev) + qc, blocks := data.TestCommitQC(rng, server.data.Registry().EpochAtTip(prev), keys, prev) if err := server.data.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("serverState.PushQC(): %w", err) } From 5d49986da6c2f561e13a5afd824b0efda48373c8 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 16:45:10 -0700 Subject: [PATCH 30/85] refactor(autobahn): use Option for optional nextDuo; drop internal header nil-check (CON-358) Internal struct fields are non-nil by construction; optional values use utils.Option instead of nil pointers. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 18 +++++++++--------- sei-tendermint/internal/autobahn/data/state.go | 10 ++++++---- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 948ca6fca6..b926745abc 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -423,21 +423,21 @@ 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 + nextDuo := utils.None[types.EpochDuo]() if closing { nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) if err != nil { return err } - nextDuo = &nt + nextDuo = utils.Some(nt) } for inner, ctrl := range s.inner.Lock() { if idx != inner.commitQCs.next { return nil } - if nextDuo != nil { - inner.advanceEpoch(*nextDuo) + if nd, ok := nextDuo.Get(); ok { + inner.advanceEpoch(nd) } inner.commitQCs.pushBack(qc) metrics.ObserveCommitQC(qc) @@ -543,13 +543,13 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return err } // Tipcut insert of a boundary CommitQC must slide Current like PushCommitQC. - var nextDuo *types.EpochDuo + nextDuo := utils.None[types.EpochDuo]() if closing { nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) if err != nil { return err } - nextDuo = &nt + nextDuo = utils.Some(nt) } for inner, ctrl := range s.inner.Lock() { updated, err := inner.prune(appQC, commitQC) @@ -563,8 +563,8 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ 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) + if nd, ok := nextDuo.Get(); ok && inner.epochDuo.Load().Current.RoadRange().Has(idx) { + inner.advanceEpoch(nd) } inner.commitQCs.pushBack(commitQC) metrics.ObserveCommitQC(commitQC) @@ -728,7 +728,7 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc if q.first > lr.First() { return nil, types.ErrPruned } - if set, ok := q.q[n].byHash[want]; ok && set.header != nil { + if set, ok := q.q[n].byHash[want]; ok { want = set.header.ParentHash() headers[len(headers)-i-1] = set.header break diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 5525d2bcf1..84e9354f10 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -481,13 +481,13 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty } } // Closing Current: WaitForDuo(tipcut) before mutating nextQC. - var nextDuo *types.EpochDuo + nextDuo := utils.None[types.EpochDuo]() if needQC && duo.Current.RoadRange().IsLastRoad(idx) { nt, err := s.cfg.Registry.WaitForDuo(ctx, idx+1) if err != nil { return err } - nextDuo = &nt + nextDuo = utils.Some(nt) } for inner, ctrl := range s.inner.Lock() { if needQC { @@ -497,8 +497,10 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty inner.qcs[inner.nextQC] = qc inner.nextQC += 1 } - if applied && nextDuo != nil { - inner.epochDuo.Store(*nextDuo) + if applied { + if nd, ok := nextDuo.Get(); ok { + inner.epochDuo.Store(nd) + } } ctrl.Updated() } From 0ec0724f7074a47e95ea15f65624ec1d2fa8ab58 Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 24 Jul 2026 16:48:25 -0700 Subject: [PATCH 31/85] refactor(autobahn): drop unused latest; fold thin twin tests (CON-358) Co-authored-by: Cursor --- .../internal/autobahn/avail/inner_test.go | 54 +++++++++---------- .../internal/autobahn/epoch/registry.go | 6 +-- .../internal/autobahn/epoch/registry_test.go | 36 +++++-------- .../internal/autobahn/epoch/testonly.go | 18 +++++-- 4 files changed, 53 insertions(+), 61 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index d3614a8552..0db7f1b4dd 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -72,21 +72,32 @@ func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber func TestNewInnerFreshStart(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 4, 0) + duo := utils.OrPanic1(registry.DuoAt(0)) - i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.None[*loadedAvailState]()) - require.NoError(t, err) - - require.False(t, i.latestAppQC.IsPresent()) - 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.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) + for _, tc := range []struct { + name string + loaded utils.Option[*loadedAvailState] + }{ + {"none", utils.None[*loadedAvailState]()}, + {"empty_loaded", utils.Some(&loadedAvailState{})}, + } { + t.Run(tc.name, func(t *testing.T) { + i, err := newInner(registry, duo, tc.loaded) + require.NoError(t, err) + + require.False(t, i.latestAppQC.IsPresent()) + 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.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) + } + }) } } @@ -104,21 +115,6 @@ func TestDecodePruneAnchorIncomplete(t *testing.T) { require.Contains(t, err.Error(), "incomplete prune anchor") } -func TestNewInnerLoadedNoAnchor(t *testing.T) { - rng := utils.TestRng() - registry, _ := epoch.GenRegistryAt(rng, 4, 0) - - loaded := &loadedAvailState{} - - 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. - require.False(t, i.latestAppQC.IsPresent()) - require.Equal(t, types.RoadIndex(0), i.commitQCs.first) - require.Equal(t, registry.FirstBlock(), i.appVotes.first) -} - func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() registry, _, m := epoch.GenRegistryTip(rng, 4) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index fac34520d8..20fa340105 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -28,8 +28,7 @@ func LastRoad(idx types.EpochIndex) types.RoadIndex { } type registryState struct { - m map[types.EpochIndex]*types.Epoch - latest types.EpochIndex + m map[types.EpochIndex]*types.Epoch } // Registry is the authoritative store of epoch/committee metadata for all @@ -66,8 +65,7 @@ func NewRegistry( ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, genesisTimestamp, committee, firstBlock) return &Registry{ state: utils.NewRWMutex(®istryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep}, - latest: 0, + m: map[types.EpochIndex]*types.Epoch{0: ep}, }), epochGen: utils.NewAtomicSend(uint64(0)), }, nil diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 7ff9124c60..ef5860d271 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -111,6 +111,17 @@ func TestSetupInitialDuo_CommitQCMidSeedsPlaceholderNext(t *testing.T) { if _, err := r.EpochAt(FirstRoad(8)); err == nil { t.Fatal("EpochAt(epoch 8) should not be present from mid-epoch CommitQC") } + duo, err := r.DuoAt(FirstRoad(5)) + if err != nil { + t.Fatalf("DuoAt(FirstRoad(5)): %v", err) + } + prev, ok := duo.Prev.Get() + if !ok || prev.EpochIndex() != 4 { + t.Fatalf("DuoAt mid-epoch Prev = %v, want epoch 4", duo.Prev) + } + if duo.Current.EpochIndex() != 5 { + t.Fatalf("DuoAt mid-epoch Current = %d, want 5", duo.Current.EpochIndex()) + } } func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { @@ -169,25 +180,6 @@ func TestDuoAt_GenesisEpoch(t *testing.T) { } } -func TestDuoAt_MiddleEpoch(t *testing.T) { - r, _ := makeRegistry(t) - tip := midRoad(2) - if err := r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})); err != nil { - t.Fatal(err) - } - duo, err := r.DuoAt(FirstRoad(2)) - if err != nil { - t.Fatalf("DuoAt(epoch 2) error: %v", err) - } - prev, ok := duo.Prev.Get() - if !ok || prev.EpochIndex() != 1 { - t.Fatalf("DuoAt(epoch 2).Prev.EpochIndex() wrong, want 1") - } - if duo.Current.EpochIndex() != 2 { - t.Fatalf("DuoAt(epoch 2).Current.EpochIndex() wrong, want 2") - } -} - func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ types.GenSecretKey(utils.TestRng()).Public(): 1, @@ -195,8 +187,7 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { 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}, - latest: 0, + m: map[types.EpochIndex]*types.Epoch{0: ep}, }), epochGen: utils.NewAtomicSend(uint64(0)), } @@ -215,8 +206,7 @@ func TestDuoAt_ErrorWhenPrevMissing(t *testing.T) { // Gap: epoch 2 present without epoch 1. bare := &Registry{ state: utils.NewRWMutex(®istryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep0, 2: ep2}, - latest: 2, + m: map[types.EpochIndex]*types.Epoch{0: ep0, 2: ep2}, }), epochGen: utils.NewAtomicSend(uint64(0)), } diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 4f2c18a379..7b665acc1a 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -7,13 +7,24 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// LatestEpoch returns the most recently activated epoch. For use in tests only. +// LatestEpoch returns the highest-index registered epoch. For use in tests only. // Do not use this to stamp CommitQC views for an arbitrary road — use // EpochAtTip (or EpochAt) so View.EpochIndex matches the road's epoch when // GenRegistry starts away from genesis. func (r *Registry) LatestEpoch() *types.Epoch { for s := range r.state.RLock() { - return s.m[s.latest] + var best types.EpochIndex + var ep *types.Epoch + for idx, e := range s.m { + if ep == nil || idx > best { + best = idx + ep = e + } + } + if ep == nil { + panic("registry has no epochs") + } + return ep } panic("unreachable") } @@ -69,8 +80,5 @@ func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumb // Duo at startEpoch only; no placeholder +1/+2 (unlike SetupInitialDuo's // CommitQC-span path). Genesis 0 always exists from NewRegistry. registry.EnsureDuoAt(FirstRoad(startEpoch)) - for s := range registry.state.Lock() { - s.latest = startEpoch - } return registry } From bf0ef23be90031817604e7871164fc8b528f7324 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 07:35:28 -0700 Subject: [PATCH 32/85] refactor(autobahn): trim EpochDuo docs and inline thin helpers (CON-358) Co-authored-by: Cursor --- sei-tendermint/autobahn/types/app_qc.go | 13 +++- sei-tendermint/autobahn/types/epoch.go | 9 +-- sei-tendermint/autobahn/types/epoch_duo.go | 78 ++++--------------- .../autobahn/types/epoch_duo_test.go | 38 --------- sei-tendermint/autobahn/types/errors.go | 14 +--- sei-tendermint/autobahn/types/proposal.go | 9 +-- .../internal/autobahn/avail/state.go | 48 ++++++------ .../autobahn/consensus/persist/persist.go | 8 +- .../autobahn/consensus/persisted_inner.go | 9 +-- .../internal/autobahn/consensus/state.go | 4 +- 10 files changed, 64 insertions(+), 166 deletions(-) diff --git a/sei-tendermint/autobahn/types/app_qc.go b/sei-tendermint/autobahn/types/app_qc.go index 712598b766..be6e813443 100644 --- a/sei-tendermint/autobahn/types/app_qc.go +++ b/sei-tendermint/autobahn/types/app_qc.go @@ -37,9 +37,16 @@ func (m *AppQC) Next() RoadIndex { func (m *AppQC) Verify(duo EpochDuo) error { p := m.Proposal() - ep, err := duo.EpochForIndex(p.EpochIndex()) - if err != nil { - return err + var ep *Epoch + switch { + case duo.Current.EpochIndex() == p.EpochIndex(): + ep = duo.Current + default: + prev, ok := duo.Prev.Get() + if !ok || prev.EpochIndex() != p.EpochIndex() { + return fmt.Errorf("epoch %d not in window %v", p.EpochIndex(), duo) + } + ep = prev } if rr := ep.RoadRange(); !rr.Has(p.RoadIndex()) { return fmt.Errorf("app road_index %v not in epoch %d roads [%v,%v)", diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index d24f37a70b..de488eb090 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -16,16 +16,12 @@ type RoadRange struct { Next RoadIndex } -// OpenRoadRange returns a RoadRange covering road indices [0, Max). -// Use in tests and genesis epochs where no upper bound is known yet. +// OpenRoadRange returns [0, Max) for tests/genesis when no upper bound is known yet. func OpenRoadRange() RoadRange { return RoadRange{First: 0, Next: utils.Max[RoadIndex]()} } -// Has reports whether idx falls within this range [First, Next). func (r RoadRange) Has(idx RoadIndex) bool { return idx >= r.First && idx < r.Next } -// IsLastRoad reports whether idx is the last road in this half-open range -// (idx+1 == Next). Tipcut seal / duo slide keys off this — same shape as -// GlobalRange.IsLastBlock. +// IsLastRoad is true when idx+1 == Next (same shape as GlobalRange.IsLastBlock). func (r RoadRange) IsLastRoad(idx RoadIndex) bool { return idx+1 == r.Next } // Epoch holds the complete context for a single epoch. @@ -39,7 +35,6 @@ type Epoch struct { firstBlock GlobalBlockNumber } -// NewEpoch constructs an Epoch. func NewEpoch(index EpochIndex, roads RoadRange, firstTimestamp time.Time, committee *Committee, firstBlock GlobalBlockNumber) *Epoch { return &Epoch{ epochIndex: index, diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index c50488f3c1..966aec2788 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -7,25 +7,16 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// EpochDuo is a sliding Prev|Current epoch window. -// -// Invariants: -// - Current is always set (construct via NewEpochDuo). -// - Prev is absent iff Current is epoch 0. -// - When Prev is present, Prev and Current are contiguous: consecutive -// epoch indices and Prev.RoadRange().Next == Current.RoadRange().First. -// -// New-committee lane traffic is admitted only after Current advances. +// EpochDuo is a Prev|Current epoch window. Current is always set; Prev is +// absent iff Current is epoch 0, otherwise contiguous with Current (index +// Current-1 and Prev.Next == Current.First). type EpochDuo struct { - Prev utils.Option[*Epoch] // absent iff Current is epoch 0; else contiguous with Current + Prev utils.Option[*Epoch] Current *Epoch } -// NewEpochDuo builds a Prev|Current window. -// -// Requirements: when prev is present, contiguous with current (index -// Current-1, Prev.Next == Current.First). Panics otherwise. Callers must pass -// non-nil Current (and non-nil Prev when present). +// NewEpochDuo builds a Prev|Current window. Panics if Prev is present but not +// contiguous with Current. func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { if p, ok := prev.Get(); ok { if want := current.EpochIndex(); p.EpochIndex()+1 != want { @@ -39,19 +30,13 @@ func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { return EpochDuo{Prev: prev, Current: current} } -// ErrRoadBeforeWindow is returned when roadIdx is older than WindowFirst. var ErrRoadBeforeWindow = errors.New("road before epoch duo window") - -// ErrRoadAfterWindow is returned when roadIdx is 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} -} - // EpochForRoad returns the epoch containing roadIdx. -// Window is [WindowFirst, Current.Next). Outside → ErrRoadBeforeWindow / -// ErrRoadAfterWindow. Current is preferred when both ranges could match. +// Window is [Prev.First or Current.First, Current.Next). Outside → +// ErrRoadBeforeWindow / ErrRoadAfterWindow. Current is preferred when both +// ranges could match. func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { if roadIdx >= w.Current.RoadRange().Next { return nil, fmt.Errorf("road %d after window %v: %w", roadIdx, w, ErrRoadAfterWindow) @@ -62,53 +47,20 @@ func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { if prev, ok := w.Prev.Get(); ok && prev.RoadRange().Has(roadIdx) { return prev, nil } - 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). -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 if roadIdx is in Current's range; else None. -// Prev is never admitted. -func (w EpochDuo) CurrentForRoad(roadIdx RoadIndex) utils.Option[*Epoch] { - if w.Current.RoadRange().Has(roadIdx) { - return utils.Some(w.Current) - } - return utils.None[*Epoch]() -} - -// WindowFirst is the earliest road in Prev|Current. -func (w EpochDuo) WindowFirst() RoadIndex { + first := w.Current.RoadRange().First if prev, ok := w.Prev.Get(); ok { - return prev.RoadRange().First + first = 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.EpochIndex() == idx { - return w.Current, nil - } - if prev, ok := w.Prev.Get(); ok && prev.EpochIndex() == idx { - return prev, nil + if roadIdx < first { + return nil, fmt.Errorf("road %d before window %v: %w", roadIdx, w, ErrRoadBeforeWindow) } - return nil, fmt.Errorf("epoch %d not in window %v", idx, w) + return nil, fmt.Errorf("road %d after window %v: %w", roadIdx, w, ErrRoadAfterWindow) } -// String returns the epoch indices in the window. func (w EpochDuo) String() string { s := "epochs [" sep := "" - for _, oep := range w.all() { + for _, oep := range [2]utils.Option[*Epoch]{utils.Some(w.Current), w.Prev} { if ep, ok := oep.Get(); ok { s += fmt.Sprintf("%s%d", sep, ep.EpochIndex()) sep = ", " diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index f3d3bc6882..20a36901e6 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -119,41 +119,3 @@ func TestEpochForRoad_BeforeAndAfterWithPrev(t *testing.T) { t.Fatalf("EpochForRoad(200) = %v, want ErrRoadAfterWindow", err) } } - -func TestWindowFirst_WithPrev(t *testing.T) { - prev, current := testDuoEpochs(t) - w := types.NewEpochDuo(current, utils.Some(prev)) - 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.NewEpochDuo(current, utils.None[*types.Epoch]()) - 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.NewEpochDuo(current, utils.Some(prev)) - 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.NewEpochDuo(current, utils.Some(prev)) - 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/autobahn/types/errors.go b/sei-tendermint/autobahn/types/errors.go index 64376fd724..e51f6a48a0 100644 --- a/sei-tendermint/autobahn/types/errors.go +++ b/sei-tendermint/autobahn/types/errors.go @@ -2,10 +2,8 @@ package types import "errors" -// ErrNotFound is returned when a requested record is not yet available — -// a block, QC, or AppProposal ahead of what data.State currently has (e.g. -// ahead of the contiguous block/QC prefix). Distinct from ErrPruned, which -// means the height is below the retention / eviction floor. +// ErrNotFound means the record is at/above the tip but not present yet +// (ahead of the contiguous prefix). Distinct from ErrPruned. var ErrNotFound = errors.New("not found") // ErrBlockGap is returned when the persisted blocks are not contiguous, @@ -17,15 +15,7 @@ var ErrBlockGap = errors.New("block gap in BlockDB") // GlobalBlockNumber is not exactly one greater than the previously written // block number. Blocks must be written densely ascending. var ErrBlockOutOfOrder = errors.New("block: WriteBlock out of order") - -// ErrQCNonContiguous is returned by WriteQC when the QC's GlobalRange().First -// does not equal the previous QC's GlobalRange().Next. QCs must be written as -// a contiguous, ascending sequence. var ErrQCNonContiguous = errors.New("block: WriteQC non-contiguous") - -// ErrBlockMissingQC is returned by WriteBlock when no previously written QC -// covers the block's GlobalBlockNumber. A QC covering a block must be written -// before that block (see the BlockDB ordering contract). var ErrBlockMissingQC = errors.New("block: WriteBlock without covering QC") // ErrPruned is returned when a requested record is below the current retention diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index cf60a54506..2cf554c34a 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -125,10 +125,8 @@ func (v View) Next() View { return v } -// ViewSpec is the full local context for starting a view: justification QCs plus -// 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. +// ViewSpec is the local context for starting a view: justification QCs plus a +// Prev|Current EpochDuo (Prev covers AppQC that lags the proposing epoch by one). type ViewSpec struct { // WARNING: currently we have implicit assumption that // TimeoutQC.View().Index == CommitQC.Index.Next(), @@ -419,8 +417,7 @@ 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). +// AppQC committee is resolved from vs.Epochs (may be Prev). func (m *FullProposal) Verify(vs ViewSpec) error { c := vs.Epoch().Committee() return scope.Parallel(func(s scope.ParallelScope) error { diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index b926745abc..40751464ca 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -17,7 +17,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) -// ErrBadLane . var ErrBadLane = errors.New("bad lane") const BlocksPerLane = 3 * types.MaxLaneRangeInProposal @@ -259,16 +258,30 @@ func (s *State) waitRoadInWindow( // waitEpochForRoad: too early waits; behind window → None (stale). func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[types.EpochDuo], error) { return s.waitRoadInWindow(ctx, roadIdx, - func(duo types.EpochDuo) utils.Option[*types.Epoch] { return duo.EpochOptForRoad(roadIdx) }, - types.EpochDuo.WindowFirst, + func(duo types.EpochDuo) utils.Option[*types.Epoch] { + if ep, err := duo.EpochForRoad(roadIdx); err == nil { + return utils.Some(ep) + } + return utils.None[*types.Epoch]() + }, + func(duo types.EpochDuo) types.RoadIndex { + if prev, ok := duo.Prev.Get(); ok { + return prev.RoadRange().First + } + return duo.Current.RoadRange().First + }, ) } -// waitCurrentForRoad blocks until roadIdx is in Current (too early); -// None if behind Current (stale). Prev is not admitted — CommitQCs are Current-only. +// waitCurrentForRoad: too early waits on Current; behind Current → None (stale). func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[types.EpochDuo], error) { return s.waitRoadInWindow(ctx, roadIdx, - func(duo types.EpochDuo) utils.Option[*types.Epoch] { return duo.CurrentForRoad(roadIdx) }, + func(duo types.EpochDuo) utils.Option[*types.Epoch] { + if duo.Current.RoadRange().Has(roadIdx) { + return utils.Some(duo.Current) + } + return utils.None[*types.Epoch]() + }, func(duo types.EpochDuo) types.RoadIndex { return duo.Current.RoadRange().First }, ) } @@ -324,17 +337,9 @@ func (s *State) waitPruneLeash(ctx context.Context, epochIdx types.EpochIndex, i panic("unreachable") } -// waitCommitEpochLeashes enforces tip-interlock before sealing epoch N>0 -// (closingEpoch = last road of N). Mid-N admits are not gated. -// incoming: AppQC on PushAppQC (None for PushCommitQC). See Registry invariants. -// -// Epoch 0 is exempt: sealing 0 does {∅,0}→{0,1} — nothing is dropped from the -// duo, so the prune leash (AppQC before dropping Prev) does not apply. Applying -// waitPruneLeash(0) would only block seal on the first AppQC for no Prev-drop -// reason. Restart with Current≥1 still requires a prune anchor (newInner); that -// path is unreachable here because lane production without an AppQC/prune is -// capped at BlocksPerLane ≪ EpochLength, so LastRoad(0) cannot be sealed -// without an earlier AppQC (and thus an anchor). +// waitCommitEpochLeashes gates seal of epoch N>0 (last road only). Epoch 0 and +// mid-epoch admits are exempt — see Registry invariants (prune/execution leash). +// incoming: AppQC on PushAppQC (None for PushCommitQC). func (s *State) waitCommitEpochLeashes( ctx context.Context, epochIdx types.EpochIndex, @@ -344,12 +349,9 @@ func (s *State) waitCommitEpochLeashes( if epochIdx == 0 || !closingEpoch { return nil } - // Sealing N drops Prev (N-1) and advances Current into N+1. - // Prune leash: AppQC in N ⇒ N-1 fully pruned before it leaves the duo. if err := s.waitPruneLeash(ctx, epochIdx, incoming); err != nil { return err } - // Execution leash: N+1 existing ⇒ execution finished N-1 (usually AdvanceIfNeeded). _, err := s.data.Registry().WaitForDuo(ctx, epoch.FirstRoad(epochIdx+1)) return err } @@ -412,7 +414,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { if !ok { return nil } - ep := duo.CurrentForRoad(idx).OrPanic("admitRoadOrDrop returned duo without Current road") + ep := duo.Current if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } @@ -464,7 +466,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if !ok { return nil } - ep := duo.EpochOptForRoad(idx).OrPanic("admitRoadOrDrop returned duo without road") + ep := utils.OrPanic1(duo.EpochForRoad(idx)) committee := ep.Committee() if err := v.VerifySig(committee); err != nil { return fmt.Errorf("v.VerifySig(): %w", err) @@ -529,7 +531,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ if !ok { return nil } - ep := duo.EpochOptForRoad(idx).OrPanic("admitRoadOrDrop returned duo without road") + ep := utils.OrPanic1(duo.EpochForRoad(idx)) if err := appQC.Verify(duo); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) } diff --git a/sei-tendermint/internal/autobahn/consensus/persist/persist.go b/sei-tendermint/internal/autobahn/consensus/persist/persist.go index 4a2dc6ca6e..69a705b156 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/persist.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/persist.go @@ -59,13 +59,11 @@ const ( headerSize = crcSize + seqSize // file header: [4-byte CRC32-C BE][8-byte seq LE] ) -// ErrNoData is returned by loadPersisted when no persisted files exist for the prefix. var ErrNoData = errors.New("no persisted data") -// ErrCorrupt indicates that a persisted file exists but contains invalid data -// (e.g. partially written during a crash). loadPersisted tolerates one corrupt -// file and falls back to the other A/B copy. OS-level errors (permission denied, -// I/O errors) are NOT wrapped with ErrCorrupt and cause loadPersisted to fail. +// ErrCorrupt: persisted file exists but is invalid (e.g. partial crash write). +// loadPersisted tolerates one corrupt A/B copy and falls back to the other; +// OS errors are not wrapped as ErrCorrupt. var ErrCorrupt = errors.New("corrupt persisted data") // dataWithSeq is the unit stored in each A/B file: a sequence number and a proto payload. diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 7ce7f294c8..44cb2d2ea7 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -72,13 +72,8 @@ type persistedInner struct { // validate checks internal consistency and cryptographic signatures of persisted state. // Returns error on corrupt state. // -// Two epochs are needed at an epoch boundary. commitEp is the epoch of the -// persisted CommitQC (used to verify the CommitQC itself). viewDuo is the -// Prev|Current window centered on the tipcut (NextIndexOpt(CommitQC)) — Current -// stamps currentView and verifies current-view artifacts; Prev is required when -// Current > 0 so ViewSpec.Epochs matches DuoAt (e.g. AppQC.Verify). -// When the CommitQC sits on the last road of an epoch, commitEp and -// viewDuo.Current differ; away from a boundary they are the same epoch. +// commitEp verifies the persisted CommitQC. viewDuo is DuoAt(tipcut): Current +// stamps/verifies the open view; at a boundary commitEp and Current differ. func (p *persistedInner) validate(commitEp *types.Epoch, viewDuo types.EpochDuo) error { viewEp := viewDuo.Current if cqc, ok := p.CommitQC.Get(); ok { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 71e935a460..9faa342ea6 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -144,8 +144,8 @@ func newState( return s, nil } -// ErrAvailBehindConsensus is returned on restart when avail's CommitQC tipcut is -// strictly behind consensus's (insane: consensus tip advances from avail). +// ErrAvailBehindConsensus: avail CommitQC tipcut < consensus tipcut on restart +// (consensus tip only advances from avail). var ErrAvailBehindConsensus = errors.New("avail CommitQC tip behind consensus tip") // CommitTipCut is the consensus view tipcut (NextIndexOpt of persisted CommitQC). From 489b3f0fbd36b7434636a387b705aaf29f386c11 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 07:39:01 -0700 Subject: [PATCH 33/85] refactor(autobahn): drop unreachable EpochForRoad after-window path (CON-358) Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 13 +-- .../autobahn/types/epoch_duo_test.go | 96 +++++++------------ 2 files changed, 35 insertions(+), 74 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 966aec2788..728fafcb2b 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -35,8 +35,8 @@ var ErrRoadAfterWindow = errors.New("road after epoch duo window") // EpochForRoad returns the epoch containing roadIdx. // Window is [Prev.First or Current.First, Current.Next). Outside → -// ErrRoadBeforeWindow / ErrRoadAfterWindow. Current is preferred when both -// ranges could match. +// ErrRoadBeforeWindow / ErrRoadAfterWindow. Under contiguous Prev|Current there +// is no gap, so a miss after the after-window check is always before-window. func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { if roadIdx >= w.Current.RoadRange().Next { return nil, fmt.Errorf("road %d after window %v: %w", roadIdx, w, ErrRoadAfterWindow) @@ -47,14 +47,7 @@ func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { if prev, ok := w.Prev.Get(); ok && prev.RoadRange().Has(roadIdx) { return prev, nil } - first := w.Current.RoadRange().First - if prev, ok := w.Prev.Get(); ok { - first = prev.RoadRange().First - } - if roadIdx < first { - 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) + return nil, fmt.Errorf("road %d before window %v: %w", roadIdx, w, ErrRoadBeforeWindow) } func (w EpochDuo) String() string { diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 20a36901e6..9b5f2dd701 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -50,72 +50,40 @@ func TestNewEpochDuo_PanicsOnNonContiguousRoads(t *testing.T) { _ = types.NewEpochDuo(current, utils.Some(prev)) } -func TestEpochForRoad_HitsCurrentEpoch(t *testing.T) { - _, current := testDuoEpochs(t) - w := types.NewEpochDuo(current, utils.None[*types.Epoch]()) - ep, err := w.EpochForRoad(150) - if err != nil { - t.Fatalf("EpochForRoad(150): %v", err) - } - if ep != current { - t.Fatalf("got %v, want current", ep) - } -} - -func TestEpochForRoad_HitsPrevEpoch(t *testing.T) { - prev, current := testDuoEpochs(t) - w := types.NewEpochDuo(current, utils.Some(prev)) - ep, err := w.EpochForRoad(50) - if err != nil { - t.Fatalf("EpochForRoad(50): %v", err) - } - if ep != prev { - t.Fatalf("got %v, want prev", ep) - } -} - -func TestEpochForRoad_OutsideWindowReturnsError(t *testing.T) { - _, current := testDuoEpochs(t) - w := types.NewEpochDuo(current, utils.None[*types.Epoch]()) - _, 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) - } -} - -func TestEpochForRoad_PastCurrentNotMaskedByPrev(t *testing.T) { +func TestEpochForRoad(t *testing.T) { prev, current := testDuoEpochs(t) - w := types.NewEpochDuo(current, utils.Some(prev)) - _, err := w.EpochForRoad(200) - if !errors.Is(err, types.ErrRoadAfterWindow) { - t.Fatalf("EpochForRoad(200) = %v, want ErrRoadAfterWindow", err) - } -} -func TestEpochForRoad_AbsentPrevSkipped(t *testing.T) { - _, current := testDuoEpochs(t) - w := types.NewEpochDuo(current, utils.None[*types.Epoch]()) - _, err := w.EpochForRoad(50) - if !errors.Is(err, types.ErrRoadBeforeWindow) { - t.Fatalf("EpochForRoad(50) with absent Prev = %v, want ErrRoadBeforeWindow", err) - } -} + currentOnly := types.NewEpochDuo(current, utils.None[*types.Epoch]()) + withPrev := types.NewEpochDuo(current, utils.Some(prev)) -func TestEpochForRoad_BeforeAndAfterWithPrev(t *testing.T) { - prev, current := testDuoEpochs(t) - w := types.NewEpochDuo(current, utils.Some(prev)) - if _, err := w.EpochForRoad(50); err != nil { - t.Fatalf("EpochForRoad(50) in prev: %v", err) - } - if _, err := w.EpochForRoad(150); err != nil { - t.Fatalf("EpochForRoad(150) in current: %v", err) - } - _, err := w.EpochForRoad(200) - if !errors.Is(err, types.ErrRoadAfterWindow) { - t.Fatalf("EpochForRoad(200) = %v, want ErrRoadAfterWindow", err) + for _, tc := range []struct { + name string + w types.EpochDuo + road types.RoadIndex + want *types.Epoch + err error + }{ + {"current_hit", currentOnly, 150, current, nil}, + {"current_before", currentOnly, 50, nil, types.ErrRoadBeforeWindow}, + {"current_after", currentOnly, 999, nil, types.ErrRoadAfterWindow}, + {"prev_hit", withPrev, 50, prev, nil}, + {"duo_current_hit", withPrev, 150, current, nil}, + {"duo_after", withPrev, 200, nil, types.ErrRoadAfterWindow}, + } { + t.Run(tc.name, func(t *testing.T) { + ep, err := tc.w.EpochForRoad(tc.road) + if tc.err != nil { + if !errors.Is(err, tc.err) { + t.Fatalf("EpochForRoad(%d) = %v, want %v", tc.road, err, tc.err) + } + return + } + if err != nil { + t.Fatalf("EpochForRoad(%d): %v", tc.road, err) + } + if ep != tc.want { + t.Fatalf("EpochForRoad(%d) = %v, want %v", tc.road, ep, tc.want) + } + }) } } From ee9b912d2df91bde15d2d9795f52e3b1543a7f07 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 07:53:06 -0700 Subject: [PATCH 34/85] fix(autobahn): bind AppQC to epoch and reject Prev Current-era globals (CON-358) Co-authored-by: Cursor --- sei-tendermint/autobahn/types/app_qc.go | 15 +++----- sei-tendermint/autobahn/types/app_qc_test.go | 24 +++++++++++++ .../autobahn/types/committee_test.go | 4 +-- sei-tendermint/autobahn/types/proposal.go | 34 +++++++++++++++++-- .../autobahn/types/proposal_test.go | 29 ++++++++++++++++ .../internal/autobahn/avail/state.go | 11 +++++- 6 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 sei-tendermint/autobahn/types/app_qc_test.go diff --git a/sei-tendermint/autobahn/types/app_qc.go b/sei-tendermint/autobahn/types/app_qc.go index be6e813443..1642b7adbb 100644 --- a/sei-tendermint/autobahn/types/app_qc.go +++ b/sei-tendermint/autobahn/types/app_qc.go @@ -35,18 +35,11 @@ func (m *AppQC) Next() RoadIndex { return m.Proposal().Next() } -func (m *AppQC) Verify(duo EpochDuo) error { +// Verify checks epoch_index / road against ep, then quorum under ep's committee. +func (m *AppQC) Verify(ep *Epoch) error { p := m.Proposal() - var ep *Epoch - switch { - case duo.Current.EpochIndex() == p.EpochIndex(): - ep = duo.Current - default: - prev, ok := duo.Prev.Get() - if !ok || prev.EpochIndex() != p.EpochIndex() { - return fmt.Errorf("epoch %d not in window %v", p.EpochIndex(), duo) - } - ep = prev + if got, want := p.EpochIndex(), ep.EpochIndex(); got != want { + return fmt.Errorf("appQC epoch %d, want %d", got, want) } if rr := ep.RoadRange(); !rr.Has(p.RoadIndex()) { return fmt.Errorf("app road_index %v not in epoch %d roads [%v,%v)", diff --git a/sei-tendermint/autobahn/types/app_qc_test.go b/sei-tendermint/autobahn/types/app_qc_test.go new file mode 100644 index 0000000000..f0d847ffd6 --- /dev/null +++ b/sei-tendermint/autobahn/types/app_qc_test.go @@ -0,0 +1,24 @@ +package types + +import ( + "testing" + "time" + + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" +) + +func TestAppQCVerifyChecksEpochAndRoad(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep := NewEpoch(1, RoadRange{First: 100, Next: 200}, time.Time{}, committee, 1) + + ok := makeAppQCFor(keys, 0, 150, GenAppHash(rng), 1) + require.NoError(t, ok.Verify(ep)) + + wrongEpoch := makeAppQCFor(keys, 0, 150, GenAppHash(rng), 0) + require.Error(t, wrongEpoch.Verify(ep)) + + wrongRoad := makeAppQCFor(keys, 0, 50, GenAppHash(rng), 1) + require.Error(t, wrongRoad.Verify(ep)) +} diff --git a/sei-tendermint/autobahn/types/committee_test.go b/sei-tendermint/autobahn/types/committee_test.go index 2364ccc507..58db7ce172 100644 --- a/sei-tendermint/autobahn/types/committee_test.go +++ b/sei-tendermint/autobahn/types/committee_test.go @@ -176,13 +176,13 @@ func TestAppQCVerifyChecksWeight(t *testing.T) { heavyOnly := NewAppQC([]*Signed[*AppVote]{ Sign(keys[0], vote), }) - require.NoError(t, heavyOnly.Verify(NewEpochDuo(ep, utils.None[*Epoch]()))) + require.NoError(t, heavyOnly.Verify(ep)) lightMajority := NewAppQC([]*Signed[*AppVote]{ Sign(keys[1], vote), Sign(keys[2], vote), }) - require.Error(t, lightMajority.Verify(NewEpochDuo(ep, utils.None[*Epoch]()))) + require.Error(t, lightMajority.Verify(ep)) } func TestTimeoutQCVerifyChecksEpochBinding(t *testing.T) { diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 2cf554c34a..80356db58f 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -126,7 +126,7 @@ func (v View) Next() View { } // ViewSpec is the local context for starting a view: justification QCs plus a -// Prev|Current EpochDuo (Prev covers AppQC that lags the proposing epoch by one). +// Prev|Current EpochDuo (AppQC epoch_index must be Current or Current-1). type ViewSpec struct { // WARNING: currently we have implicit assumption that // TimeoutQC.View().Index == CommitQC.Index.Next(), @@ -417,7 +417,8 @@ func (m *FullProposal) TimeoutQC() utils.Option[*TimeoutQC] { } // Verify verifies the FullProposal against the current view. -// AppQC committee is resolved from vs.Epochs (may be Prev). +// AppQC epoch_index must be Current or Current-1; Prev AppQCs cannot attest +// Current-era globals. func (m *FullProposal) Verify(vs ViewSpec) error { c := vs.Epoch().Committee() return scope.Parallel(func(s scope.ParallelScope) error { @@ -513,11 +514,38 @@ func (m *FullProposal) Verify(vs ViewSpec) error { return errors.New("appQC doesn't match the proposal") } s.Spawn(func() error { - if err := appQC.Verify(vs.Epochs); err != nil { + appEpoch := appQC.Proposal().EpochIndex() + cur := vs.Epoch().EpochIndex() + // AppQC may lag the tipcut by at most one epoch. + if appEpoch != cur && (cur == 0 || appEpoch != cur-1) { + if cur == 0 { + return fmt.Errorf("appQC epoch %d, want %d", appEpoch, cur) + } + return fmt.Errorf("appQC epoch %d, want %d or %d", appEpoch, cur, cur-1) + } + ep := vs.Epochs.Current + if appEpoch != cur { + prev, ok := vs.Epochs.Prev.Get() + if !ok { + return fmt.Errorf("appQC epoch %d but Prev missing from duo %v", appEpoch, vs.Epochs) + } + ep = prev + } + if err := appQC.Verify(ep); err != nil { return fmt.Errorf("appQC: %w", err) } return nil }) + if got, want := appQC.Proposal().RoadIndex(), vs.View().Index; got > want { + return fmt.Errorf("appQC road %d ahead of proposal view %d", got, want) + } + // Prev-epoch AppQC may lag the tipcut, but must not attest Current-era + // globals (old committee attesting new-epoch blocks). + if appQC.Proposal().EpochIndex() < vs.Epoch().EpochIndex() { + if got, want := appQC.Proposal().GlobalNumber(), vs.Epoch().FirstBlock(); got >= want { + return fmt.Errorf("prev-epoch appQC global %d, want < current first block %d", got, want) + } + } if got, want := appQC.Proposal().GlobalNumber(), vs.NextGlobalBlock(); got >= want { return fmt.Errorf("appQC for block %v, while only %v blocks were finalized", got, want) } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 6e646e8c52..0b4c70263f 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -733,6 +733,35 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { require.Error(t, fpWrong.Verify(vs)) } +func TestProposalVerifyRejectsPrevAppQCCurrentGlobal(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + vs := viewSpecContiguousDuo(keys, committee) + leader := leaderKey(committee, keys, vs.View()) + + // Prev-epoch AppQC whose GlobalNumber is already in Current's range. + app := NewAppProposal(vs.Epoch().FirstBlock(), 0, GenAppHash(rng), 0) + var votes []*Signed[*AppVote] + for _, k := range keys { + votes = append(votes, Sign(k, NewAppVote(app))) + } + appQC := NewAppQC(votes) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(appQC))) + require.Error(t, fp.Verify(vs)) +} + +func TestProposalVerifyRejectsAppQCRoadAheadOfView(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + vs := viewSpecContiguousDuo(keys, committee) + leader := leaderKey(committee, keys, vs.View()) + + ahead := vs.View().Index + 1 + appQC := makeAppQCFor(keys, 0, ahead, GenAppHash(rng), 1) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(appQC))) + require.Error(t, fp.Verify(vs)) +} + func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { for _, tc := range []struct { name string diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 40751464ca..82dd3bf6c0 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -532,7 +532,16 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } ep := utils.OrPanic1(duo.EpochForRoad(idx)) - if err := appQC.Verify(duo); err != nil { + appEpoch := appQC.Proposal().EpochIndex() + cur := duo.Current.EpochIndex() + // AppQC may lag the tipcut by at most one epoch. + if appEpoch != cur && (cur == 0 || appEpoch != cur-1) { + if cur == 0 { + return fmt.Errorf("appQC epoch %d, want %d", appEpoch, cur) + } + return fmt.Errorf("appQC epoch %d, want %d or %d", appEpoch, cur, cur-1) + } + if err := appQC.Verify(ep); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) } if err := commitQC.Verify(ep); err != nil { From 0b66271799896534d4e8a6bbb12d71fce81b92b7 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 08:35:14 -0700 Subject: [PATCH 35/85] fix(autobahn): tipcut AppQC matches main; harden admit and tip checks (CON-358) Require tipcut AppQC same-epoch as the proposal (drop FirstBlock/Prev lag guards). Verify AppVotes before waitForCommitQC, advance Sync only after admit, and return an error from data.CommitTipCut on a missing cached QC. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 38 +---- .../autobahn/types/proposal_test.go | 150 +++++------------- .../internal/autobahn/avail/inner.go | 3 + .../internal/autobahn/avail/state.go | 34 ++-- .../internal/autobahn/avail/state_test.go | 23 ++- .../internal/autobahn/avail/testonly.go | 8 +- .../internal/autobahn/consensus/state_test.go | 3 +- .../internal/autobahn/data/state.go | 13 +- .../internal/p2p/giga_router_validator.go | 6 +- 9 files changed, 117 insertions(+), 161 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 80356db58f..ae988e4322 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -126,7 +126,7 @@ func (v View) Next() View { } // ViewSpec is the local context for starting a view: justification QCs plus a -// Prev|Current EpochDuo (AppQC epoch_index must be Current or Current-1). +// Prev|Current EpochDuo (CommitQC may sit in Prev while the tipcut is in Current). type ViewSpec struct { // WARNING: currently we have implicit assumption that // TimeoutQC.View().Index == CommitQC.Index.Next(), @@ -417,8 +417,7 @@ func (m *FullProposal) TimeoutQC() utils.Option[*TimeoutQC] { } // Verify verifies the FullProposal against the current view. -// AppQC epoch_index must be Current or Current-1; Prev AppQCs cannot attest -// Current-era globals. +// AppQC must match the proposal epoch (same as main): no Prev-epoch lag on tipcuts. func (m *FullProposal) Verify(vs ViewSpec) error { c := vs.Epoch().Committee() return scope.Parallel(func(s scope.ParallelScope) error { @@ -506,6 +505,10 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } } else { app, _ := m.proposal.Msg().App().Get() + // Same-epoch as the tipcut proposal (main). Prev lag is avail-only. + 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, ok := m.appQC.Get() if !ok { return errors.New("appQC missing") @@ -514,38 +517,11 @@ func (m *FullProposal) Verify(vs ViewSpec) error { return errors.New("appQC doesn't match the proposal") } s.Spawn(func() error { - appEpoch := appQC.Proposal().EpochIndex() - cur := vs.Epoch().EpochIndex() - // AppQC may lag the tipcut by at most one epoch. - if appEpoch != cur && (cur == 0 || appEpoch != cur-1) { - if cur == 0 { - return fmt.Errorf("appQC epoch %d, want %d", appEpoch, cur) - } - return fmt.Errorf("appQC epoch %d, want %d or %d", appEpoch, cur, cur-1) - } - ep := vs.Epochs.Current - if appEpoch != cur { - prev, ok := vs.Epochs.Prev.Get() - if !ok { - return fmt.Errorf("appQC epoch %d but Prev missing from duo %v", appEpoch, vs.Epochs) - } - ep = prev - } - if err := appQC.Verify(ep); err != nil { + if err := appQC.Verify(vs.Epoch()); err != nil { return fmt.Errorf("appQC: %w", err) } return nil }) - if got, want := appQC.Proposal().RoadIndex(), vs.View().Index; got > want { - return fmt.Errorf("appQC road %d ahead of proposal view %d", got, want) - } - // Prev-epoch AppQC may lag the tipcut, but must not attest Current-era - // globals (old committee attesting new-epoch blocks). - if appQC.Proposal().EpochIndex() < vs.Epoch().EpochIndex() { - if got, want := appQC.Proposal().GlobalNumber(), vs.Epoch().FirstBlock(); got >= want { - return fmt.Errorf("prev-epoch appQC global %d, want < current first block %d", got, want) - } - } if got, want := appQC.Proposal().GlobalNumber(), vs.NextGlobalBlock(); got >= want { return fmt.Errorf("appQC for block %v, while only %v blocks were finalized", got, want) } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 0b4c70263f..962656dfc9 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -22,7 +22,7 @@ func genFreshEpoch(rng utils.Rng, committee *Committee) *Epoch { } // viewSpecContiguousDuo builds Prev|Current with abutting roads [0,1)|[1,Max) and a -// CommitQC so View.Index is inside Current (for AppQC Prev|Current verify tests). +// CommitQC so View.Index is inside Current (for AppQC verify tests). func viewSpecContiguousDuo(keys []SecretKey, committee *Committee) ViewSpec { const split RoadIndex = 1 prev := NewEpoch(0, RoadRange{First: 0, Next: split}, time.Time{}, committee, 1) @@ -643,60 +643,37 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { } func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { - 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) - vs := viewSpecContiguousDuo(keys, committee) - leader := leaderKey(committee, keys, vs.View()) - - // Fresh AppQC: globalNumber 0 < FirstBlock 1. Road must sit in the AppQC epoch. - road := RoadIndex(tc.appEpoch) // epoch 0 → road 0; epoch 1 → road 1 (split) - goodAppQC := makeAppQCFor(keys, 0, road, GenAppHash(rng), tc.appEpoch) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) - - tamperedFP := &FullProposal{ - proposal: fp.proposal, - laneQCs: fp.laneQCs, - } - require.Error(t, tamperedFP.Verify(vs)) - }) + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + vs := viewSpecContiguousDuo(keys, committee) + leader := leaderKey(committee, keys, vs.View()) + + goodAppQC := makeAppQCFor(keys, 0, 1, GenAppHash(rng), 1) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) + + tamperedFP := &FullProposal{ + proposal: fp.proposal, + laneQCs: fp.laneQCs, } + require.Error(t, tamperedFP.Verify(vs)) } func TestProposalVerifyRejectsAppQCMismatch(t *testing.T) { - 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) - vs := viewSpecContiguousDuo(keys, committee) - leader := leaderKey(committee, keys, vs.View()) - - road := RoadIndex(tc.appEpoch) - goodAppQC := makeAppQCFor(keys, 0, road, GenAppHash(rng), tc.appEpoch) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) - - differentAppQC := makeAppQCFor(keys, 0, road, GenAppHash(rng), tc.appEpoch) - tamperedFP := &FullProposal{ - proposal: fp.proposal, - laneQCs: fp.laneQCs, - appQC: utils.Some(differentAppQC), - } - require.Error(t, tamperedFP.Verify(vs)) - }) + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + vs := viewSpecContiguousDuo(keys, committee) + leader := leaderKey(committee, keys, vs.View()) + + goodAppQC := makeAppQCFor(keys, 0, 1, GenAppHash(rng), 1) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) + + differentAppQC := makeAppQCFor(keys, 0, 1, GenAppHash(rng), 1) + tamperedFP := &FullProposal{ + proposal: fp.proposal, + laneQCs: fp.laneQCs, + appQC: utils.Some(differentAppQC), } + require.Error(t, tamperedFP.Verify(vs)) } func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { @@ -724,77 +701,36 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(1)))) require.NoError(t, fp.Verify(vs)) - // AppQC from Prev (N-1) — accepted. + // AppQC from Prev — rejected (tipcut AppQC matches proposal epoch, as on main). fpPrev := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(0)))) - require.NoError(t, fpPrev.Verify(vs)) + require.Error(t, fpPrev.Verify(vs)) // AppQC outside the duo — rejected. fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2)))) require.Error(t, fpWrong.Verify(vs)) } -func TestProposalVerifyRejectsPrevAppQCCurrentGlobal(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - vs := viewSpecContiguousDuo(keys, committee) - leader := leaderKey(committee, keys, vs.View()) - - // Prev-epoch AppQC whose GlobalNumber is already in Current's range. - app := NewAppProposal(vs.Epoch().FirstBlock(), 0, GenAppHash(rng), 0) - var votes []*Signed[*AppVote] - for _, k := range keys { - votes = append(votes, Sign(k, NewAppVote(app))) - } - appQC := NewAppQC(votes) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(appQC))) - require.Error(t, fp.Verify(vs)) -} - -func TestProposalVerifyRejectsAppQCRoadAheadOfView(t *testing.T) { +func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) vs := viewSpecContiguousDuo(keys, committee) leader := leaderKey(committee, keys, vs.View()) - ahead := vs.View().Index + 1 - appQC := makeAppQCFor(keys, 0, ahead, GenAppHash(rng), 1) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(appQC))) - require.Error(t, fp.Verify(vs)) -} + appHash := GenAppHash(rng) + goodAppQC := makeAppQCFor(keys, 0, 1, appHash, 1) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) -func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { - 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) - vs := viewSpecContiguousDuo(keys, committee) - leader := leaderKey(committee, keys, vs.View()) - - appHash := GenAppHash(rng) - road := RoadIndex(tc.appEpoch) - goodAppQC := makeAppQCFor(keys, 0, road, appHash, tc.appEpoch) - 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)) - for i := range otherKeys { - otherKeys[i] = GenSecretKey(rng) - } - badAppQC := makeAppQCFor(otherKeys, 0, road, appHash, tc.appEpoch) - tamperedFP := &FullProposal{ - proposal: fp.proposal, - laneQCs: fp.laneQCs, - appQC: utils.Some(badAppQC), - } - require.Error(t, tamperedFP.Verify(vs)) - }) + otherKeys := make([]SecretKey, len(keys)) + for i := range otherKeys { + otherKeys[i] = GenSecretKey(rng) + } + badAppQC := makeAppQCFor(otherKeys, 0, 1, appHash, 1) + tamperedFP := &FullProposal{ + proposal: fp.proposal, + laneQCs: fp.laneQCs, + appQC: utils.Some(badAppQC), } + require.Error(t, tamperedFP.Verify(vs)) } func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 5344bb5896..ed6be7979d 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -78,6 +78,9 @@ func (ls *loadedAvailState) nextCommitQC() types.RoadIndex { func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailState]) (*inner, error) { lanes := map[types.LaneID]*laneState{} + // TODO(lane-id): also seed Prev lanes before prune so restart applies the + // anchor watermark to them (today only Current is pre-created; Prev lanes + // appear later via WAL getOrInsertLane and miss prune). Next Lane ID PR. for lane := range startEpochDuo.Current.Committee().Lanes().All() { lanes[lane] = newLaneState() } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 82dd3bf6c0..dd1e217218 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -174,6 +174,8 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin // Truncate WAL entries below the prune anchor that were filtered out by // loadPersistedState. Lanes come from the operating (tip) duo. + // TODO(lane-id): also prune Prev committee lanes on restart (same as + // newInner Prev-lane seeding). Next Lane ID PR. if ls, ok := loaded.Get(); ok { if anchor, ok := ls.pruneAnchor.Get(); ok { for lane := range startDuo.Current.Committee().Lanes().All() { @@ -453,6 +455,20 @@ 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() + // Authenticate before waitForCommitQC — a far-future RoadIndex on an + // unverified vote would otherwise park this goroutine until ctx cancel + // (same stall shape PushAppQC rejects before admitRoadOrDrop). + ep, err := s.data.Registry().EpochAt(idx) + if err != nil { + return fmt.Errorf("EpochAt(%d): %w", idx, err) + } + if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want { + return fmt.Errorf("appVote epoch_index %d, want %d", got, want) + } + committee := ep.Committee() + if err := v.VerifySig(committee); err != nil { + return fmt.Errorf("v.VerifySig(): %w", err) + } // A vote may arrive before its CommitQC advances the tip. if err := s.waitForCommitQC(ctx, idx); err != nil { return err @@ -466,11 +482,8 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if !ok { return nil } - ep := utils.OrPanic1(duo.EpochForRoad(idx)) - committee := ep.Committee() - if err := v.VerifySig(committee); err != nil { - return fmt.Errorf("v.VerifySig(): %w", err) - } + ep = utils.OrPanic1(duo.EpochForRoad(idx)) + committee = ep.Committee() 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) { @@ -532,15 +545,6 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } ep := utils.OrPanic1(duo.EpochForRoad(idx)) - appEpoch := appQC.Proposal().EpochIndex() - cur := duo.Current.EpochIndex() - // AppQC may lag the tipcut by at most one epoch. - if appEpoch != cur && (cur == 0 || appEpoch != cur-1) { - if cur == 0 { - return fmt.Errorf("appQC epoch %d, want %d", appEpoch, cur) - } - return fmt.Errorf("appQC epoch %d, want %d or %d", appEpoch, cur, cur-1) - } if err := appQC.Verify(ep); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) } @@ -873,6 +877,8 @@ func (s *State) Run(ctx context.Context) error { return s.runPersist(ctx, s.persisters) }) // Task inserting FullCommitQCs and local blocks to data state. + // ErrPruned jumps n forward (AppQC/window prune during catch-up): skipped + // roads need not be exported locally — peers can PushQC into data. scope.SpawnNamed("s.data.PushQC", func() error { for n := types.RoadIndex(0); ; n = max(n+1, s.FirstCommitQC()) { qc, err := s.fullCommitQC(ctx, n) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index f67502b2b6..2f6c38b9f3 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1018,7 +1018,7 @@ func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { require.NoError(t, err) // AppVote for Current's first road while CommitQC tip is still behind: - // PushAppVote blocks in waitForCommitQC (not on the epoch window). + // after VerifySig, PushAppVote blocks in waitForCommitQC (not on the epoch window). epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) proposal := types.NewAppProposal( epM.FirstBlock(), epM.RoadRange().First, types.GenAppHash(rng), epM.EpochIndex()) @@ -1029,6 +1029,27 @@ func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) } +// TestPushAppVoteUnregisteredRoadRejectsBeforeWait: a far-future RoadIndex whose +// epoch is not seeded must fail EpochAt immediately — not park in waitForCommitQC. +func TestPushAppVoteUnregisteredRoadRejectsBeforeWait(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + far := epoch.FirstRoad(m + 10) + proposal := types.NewAppProposal(0, far, types.GenAppHash(rng), m+10) + vote := types.Sign(keys[0], types.NewAppVote(proposal)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() // would surface as Canceled if we waited first + + err = state.PushAppVote(ctx, vote) + require.Error(t, err) + require.NotErrorIs(t, err, context.Canceled) + require.Contains(t, err.Error(), "EpochAt") +} + // TestWaitCurrentForRoadPrevNotAdmitted: a road in Prev is too late for // Current-only admission (CommitQC), even though waitEpochForRoad would // still resolve it. diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index 2f190d5248..1af8e8da1e 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -61,10 +61,16 @@ func RunTestNetwork(ctx context.Context, states []*State) error { } return err } - next = qc.Index() + 1 if err := to.PushCommitQC(ctx, qc); err != nil { return err } + // PushCommitQC may no-op (stale / not yet Current) and still + // return nil. Only advance once to's tip covers this road so + // we do not skip an index that was never admitted. + if err := to.waitForCommitQC(ctx, qc.Index()); err != nil { + return err + } + next = qc.Index() + 1 } }) } diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 1481f67810..c004b57f95 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -421,7 +421,8 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{ Registry: registry, }, db2)) - dataTip := ds.CommitTipCut() + dataTip, err := ds.CommitTipCut() + require.NoError(t, err) require.Equal(t, n, epoch.IndexForRoad(dataTip), "data tipcut must stay in epoch N") _, err = registry.EpochAt(leadTip) require.NoError(t, err, "executed in data-tip epoch must seed N+1") diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 84e9354f10..45cee9104e 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -403,16 +403,19 @@ 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 (avail tip vs data). -func (s *State) CommitTipCut() types.RoadIndex { +// Returns an error if nextQC claims a retained QC that is missing from the map +// (invariant break) — not the same as an empty tip. +func (s *State) CommitTipCut() (types.RoadIndex, error) { for inner := range s.inner.Lock() { if inner.nextQC == 0 || inner.nextQC <= inner.first { - return 0 + return 0, nil } - qc := inner.qcs[inner.nextQC-1] + n := inner.nextQC - 1 + qc := inner.qcs[n] if qc == nil { - return 0 + return 0, fmt.Errorf("CommitTipCut: missing QC at global %d (first=%d nextQC=%d)", n, inner.first, inner.nextQC) } - return qc.QC().Proposal().Index() + 1 + return qc.QC().Proposal().Index() + 1, nil } panic("unreachable") } diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index a93ef1613a..59a60c4c72 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -56,7 +56,11 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey, dataSta if err != nil { return nil, fmt.Errorf("consensus.NewState(): %w", err) } - if err := checkRestartTips(dataState.CommitTipCut(), consensusState.Avail().CommitTipCut()); err != nil { + dataTip, err := dataState.CommitTipCut() + if err != nil { + return nil, fmt.Errorf("data.CommitTipCut(): %w", err) + } + if err := checkRestartTips(dataTip, consensusState.Avail().CommitTipCut()); err != nil { return nil, err } producerState := producer.NewState(cfg.Producer, consensusState, cfg.App) From 0695c33b38289b3c90ce7ab0baa951df7ca0140b Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 08:46:28 -0700 Subject: [PATCH 36/85] =?UTF-8?q?fix(autobahn):=20drop=20avail=E2=89=A5dat?= =?UTF-8?q?a=20restart=20tip=20hard-fail=20(CON-358)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peer FullCommitQC sync and async BlockDB flush can leave data ahead of avail without corruption; Run catch-up repairs it. Keep avail≥consensus only. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 3 ++- .../internal/autobahn/consensus/inner.go | 4 +-- .../internal/autobahn/data/state.go | 2 +- .../internal/autobahn/epoch/registry.go | 5 ++-- .../internal/p2p/giga_router_validator.go | 26 ------------------- .../p2p/giga_router_validator_test.go | 10 ------- 6 files changed, 8 insertions(+), 42 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index dd1e217218..b95e011348 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -158,7 +158,8 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin } // DuoAt(CommitQC tipcut). Seeding is data.SetupInitialDuo; missing epoch hard-fails. - // Tip order: consensus.NewState (avail≥consensus), p2p.checkRestartTips (avail≥data). + // Tip order: consensus.NewState requires avail ≥ consensus; avail/consensus + // may lag data and catch up in Run. commitTip := types.RoadIndex(0) if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index cadd5da92f..8d14797ce9 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -121,8 +121,8 @@ func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) ( } // View duo = tipcut; CommitQC may be prior epoch. Seeding is data's; - // missing epoch hard-fails. Tip order: NewState (avail≥consensus); - // checkRestartTips (avail≥data; consensus may lag data and catch up). + // missing epoch hard-fails. Tip order: NewState requires avail ≥ consensus; + // avail/consensus may lag data and catch up in Run. nextViewRoad := types.NextIndexOpt(persisted.CommitQC) duo, err := registry.DuoAt(nextViewRoad) if err != nil { diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 45cee9104e..19ec3ea26c 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -402,7 +402,7 @@ func (s *State) FirstAppProposal() types.GlobalBlockNumber { 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 (avail tip vs data). +// none. // Returns an error if nextQC claims a retained QC that is missing from the map // (invariant break) — not the same as an empty tip. func (s *State) CommitTipCut() (types.RoadIndex, error) { diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 20fa340105..07cbc8751a 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -43,8 +43,9 @@ type registryState struct { // nothing. Finishing LastRoad(N-1) seeds epoch N+1 (AdvanceIfNeeded). // - data/ is the sole restart seeder (SetupInitialDuo). Avail/consensus must // not seed; tip into an unseeded epoch → EpochAt/DuoAt hard-fail. -// - Post-construction tipcuts: avail ≥ consensus and avail ≥ data. -// Consensus may lag data (catch-up from avail in Run); avail < data → hard-fail. +// - Post-construction tipcuts: avail ≥ consensus. Consensus and avail may +// lag data (peer FullCommitQC / async BlockDB flush); catch-up from peers +// and avail LastCommitQC in Run closes the gap. // - Placeholders use the genesis committee until real committees are wired. // // TODO(autobahn): replace genesis placeholders with epoch info on blocks. diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 59a60c4c72..d56ca599db 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -2,7 +2,6 @@ package p2p import ( "context" - "errors" "fmt" "net/url" @@ -16,24 +15,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) -// ErrTipBehindData is returned on restart when avail's CommitQC tipcut is -// strictly behind data's (data QCs are exported from avail — reverse is corrupt). -var ErrTipBehindData = errors.New("CommitQC tip behind data tip") - -// checkRestartTips ensures avail tipcut is not behind data. -// Call after data + consensus are constructed. Avail vs consensus is checked -// inside consensus.NewState (avail ≥ consensus). -// -// Consensus may lag data: FullCommitQC can reach BlockDB before consensus's -// LastCommitQC catch-up persists. That is fine — Run()'s pushCommitQC advances -// consensus from avail. Only avail < data is insane. -func checkRestartTips(dataTip, availTip atypes.RoadIndex) error { - if availTip < dataTip { - return fmt.Errorf("%w: avail tipcut %d < data tipcut %d", ErrTipBehindData, availTip, dataTip) - } - return nil -} - type gigaValidatorRouter struct { *gigaRouterCommon @@ -56,13 +37,6 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey, dataSta if err != nil { return nil, fmt.Errorf("consensus.NewState(): %w", err) } - dataTip, err := dataState.CommitTipCut() - if err != nil { - return nil, fmt.Errorf("data.CommitTipCut(): %w", err) - } - if err := checkRestartTips(dataTip, consensusState.Avail().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 8785a97c98..7bf51376a4 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -351,13 +351,3 @@ func TestGigaRouter_EvmProxy(t *testing.T) { }) require.NoError(t, err) } - -func TestCheckRestartTips(t *testing.T) { - require.NoError(t, checkRestartTips(0, 0)) - require.NoError(t, checkRestartTips(1, 1)) - require.NoError(t, checkRestartTips(1, 2), "avail lead is fine") - require.NoError(t, checkRestartTips(2, 2)) - // data ahead of avail is corrupt; consensus lagging data is OK (not checked here). - require.ErrorIs(t, checkRestartTips(2, 1), ErrTipBehindData) - require.ErrorIs(t, checkRestartTips(2, 0), ErrTipBehindData) -} From 38f9cb52e67bc0e926dd9ecdc51cf246917cfa15 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 08:58:16 -0700 Subject: [PATCH 37/85] fix(autobahn): allow tipcut AppQC Current-1 lag; require road < view (CON-358) Prev-epoch AppQC can still be unfinished at a boundary, so tipcuts accept Current|Current-1 and buildProposal clears same-road/ahead AppQC instead of shipping invalid proposals. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 40 +++++++++++-- .../autobahn/types/proposal_test.go | 60 ++++++++++++++++--- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index ae988e4322..4fdf197d15 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -126,7 +126,7 @@ func (v View) Next() View { } // ViewSpec is the local context for starting a view: justification QCs plus a -// Prev|Current EpochDuo (CommitQC may sit in Prev while the tipcut is in Current). +// Prev|Current EpochDuo. Tipcut AppQC may be Current or Current-1 (Prev lag). type ViewSpec struct { // WARNING: currently we have implicit assumption that // TimeoutQC.View().Index == CommitQC.Index.Next(), @@ -362,6 +362,21 @@ func buildProposal( app = utils.None[*AppProposal]() appQC = utils.None[*AppQC]() } + // AppQC must be for a prior tipcut (road < view). Same-road or ahead can show up + // on restart/gossip races; drop it so the tipcut stays valid. + if a, ok := app.Get(); ok && a.RoadIndex() >= viewSpec.View().Index { + app = AppOpt(ProposalOpt(viewSpec.CommitQC)) + appQC = utils.None[*AppQC]() + } + // Tipcut AppQC may lag one epoch (Current or Current-1). Outside that window, + // fall back to the previous CommitQC's app so the tipcut stays valid. + if a, ok := app.Get(); ok { + appEp, cur := a.EpochIndex(), viewSpec.Epoch().EpochIndex() + if appEp != cur && (cur == 0 || appEp != cur-1) { + app = AppOpt(ProposalOpt(viewSpec.CommitQC)) + appQC = utils.None[*AppQC]() + } + } // Normalize the creation timestamp. if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { timestamp = wantMin @@ -417,7 +432,7 @@ func (m *FullProposal) TimeoutQC() utils.Option[*TimeoutQC] { } // Verify verifies the FullProposal against the current view. -// AppQC must match the proposal epoch (same as main): no Prev-epoch lag on tipcuts. +// Tipcut AppQC may be Current or Current-1 (Prev lag for unfinished AppQC). func (m *FullProposal) Verify(vs ViewSpec) error { c := vs.Epoch().Committee() return scope.Parallel(func(s scope.ParallelScope) error { @@ -505,9 +520,11 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } } else { app, _ := m.proposal.Msg().App().Get() - // Same-epoch as the tipcut proposal (main). Prev lag is avail-only. - if got, want := app.EpochIndex(), m.proposal.Msg().EpochIndex(); got != want { - return fmt.Errorf("app epoch_index %d != proposal epoch_index %d", got, want) + appEpoch := app.EpochIndex() + cur := vs.Epoch().EpochIndex() + // Allow Current or Current-1 (Prev lag). Reject anything else. + if appEpoch != cur && (cur == 0 || appEpoch != cur-1) { + return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEpoch, cur) } appQC, ok := m.appQC.Get() if !ok { @@ -517,11 +534,22 @@ func (m *FullProposal) Verify(vs ViewSpec) error { return errors.New("appQC doesn't match the proposal") } s.Spawn(func() error { - if err := appQC.Verify(vs.Epoch()); err != nil { + ep := vs.Epochs.Current + if appEpoch != cur { + prev, ok := vs.Epochs.Prev.Get() + if !ok { + return fmt.Errorf("appQC epoch %d needs Prev, but Prev is absent", appEpoch) + } + ep = prev + } + if err := appQC.Verify(ep); err != nil { return fmt.Errorf("appQC: %w", err) } return nil }) + if got, want := appQC.Proposal().RoadIndex(), vs.View().Index; got >= want { + return fmt.Errorf("appQC road %v ahead of tipcut view %v", got, want) + } if got, want := appQC.Proposal().GlobalNumber(), vs.NextGlobalBlock(); got >= want { return fmt.Errorf("appQC for block %v, while only %v blocks were finalized", got, want) } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 962656dfc9..d74a1089b0 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -21,14 +21,16 @@ func genFreshEpoch(rng utils.Rng, committee *Committee) *Epoch { ) } -// viewSpecContiguousDuo builds Prev|Current with abutting roads [0,1)|[1,Max) and a -// CommitQC so View.Index is inside Current (for AppQC verify tests). +// viewSpecContiguousDuo builds Prev|Current with abutting roads [0,1)|[1,Max) and +// CommitQCs advanced into Current so View.Index == 2. That leaves room for a +// Current-epoch AppQC at road 1 (strictly before the tipcut view). func viewSpecContiguousDuo(keys []SecretKey, committee *Committee) ViewSpec { const split RoadIndex = 1 prev := NewEpoch(0, RoadRange{First: 0, Next: split}, time.Time{}, committee, 1) current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 1) - qc := BuildCommitQC(prev, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) - return ViewSpec{CommitQC: utils.Some(qc), Epochs: NewEpochDuo(current, utils.Some(prev))} + qc0 := BuildCommitQC(prev, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + qc1 := BuildCommitQC(current, keys, utils.Some(qc0), nil, utils.None[*AppQC]()) + return ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(current, utils.Some(prev))} } // leaderKey returns the secret key for the leader of the given view. @@ -701,13 +703,55 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(1)))) require.NoError(t, fp.Verify(vs)) - // AppQC from Prev — rejected (tipcut AppQC matches proposal epoch, as on main). + // AppQC from Prev (Current-1 lag) — accepted. fpPrev := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(0)))) - require.Error(t, fpPrev.Verify(vs)) + require.NoError(t, fpPrev.Verify(vs)) + appPrev, ok := fpPrev.Proposal().Msg().App().Get() + require.True(t, ok) + require.Equal(t, EpochIndex(0), appPrev.EpochIndex()) - // AppQC outside the duo — rejected. + // AppQC outside {Current, Current-1} — cleared by buildProposal. fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2)))) - require.Error(t, fpWrong.Verify(vs)) + require.False(t, fpWrong.appQC.IsPresent()) + require.NoError(t, fpWrong.Verify(vs)) +} + +func TestProposalClearsAppQCRoadNotBeforeView(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + vs := viewSpecContiguousDuo(keys, committee) + leader := leaderKey(committee, keys, vs.View()) + view := vs.View().Index + lanes := oneLaneQCMap(rng, committee, keys, vs) + + // buildProposal clears AppQC at road == view (and ahead) so the tipcut stays valid. + fpSame := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(makeAppQCFor(keys, 0, view, GenAppHash(rng), 1)))) + require.False(t, fpSame.appQC.IsPresent()) + require.NoError(t, fpSame.Verify(vs)) + + fpAhead := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(makeAppQCFor(keys, 0, view+1, GenAppHash(rng), 1)))) + require.False(t, fpAhead.appQC.IsPresent()) + require.NoError(t, fpAhead.Verify(vs)) + + // AppQC for the justifying CommitQC road (view-1) — kept and accepted. + fpOk := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(makeAppQCFor(keys, 0, view-1, GenAppHash(rng), 1)))) + require.True(t, fpOk.appQC.IsPresent()) + require.NoError(t, fpOk.Verify(vs)) + + // Verify still rejects a tipcut that bypasses buildProposal with same-road AppQC. + bad := makeAppQCFor(keys, 0, view, GenAppHash(rng), 1) + base := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.None[*AppQC]())) + baseMsg := base.proposal.Msg() + ranges := make([]*LaneRange, 0, len(baseMsg.laneRanges)) + for _, r := range baseMsg.laneRanges { + ranges = append(ranges, r) + } + tampered := &FullProposal{ + proposal: Sign(leader, newProposal(vs.View(), baseMsg.Timestamp(), ranges, utils.Some(bad.Proposal()), vs.NextGlobalBlock())), + laneQCs: base.laneQCs, + appQC: utils.Some(bad), + } + require.Error(t, tampered.Verify(vs)) } func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { From 858cc94e3d7956efb0fd97e349f0427e8915f2da Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 09:26:01 -0700 Subject: [PATCH 38/85] fix(autobahn): TestCommitQC AppQC uses prior tipcut road (CON-358) buildProposal clears AppQC when road >= view; the helper was tagging AppQC with View.Index so CommitQCs lost their embedded App and pruning tests failed. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/testonly.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/testonly.go b/sei-tendermint/internal/autobahn/data/testonly.go index 7026668be9..1ce25de357 100644 --- a/sei-tendermint/internal/autobahn/data/testonly.go +++ b/sei-tendermint/internal/autobahn/data/testonly.go @@ -60,8 +60,14 @@ func TestCommitQC( } var appQC utils.Option[*types.AppQC] if cqc, ok := prev.Get(); ok { - vs := types.ViewSpec{CommitQC: prev, Epochs: types.NewEpochDuo(ep, utils.None[*types.Epoch]())} - p := types.NewAppProposal(cqc.GlobalRange().Next-1, vs.View().Index, types.GenAppHash(rng), ep.EpochIndex()) + // AppQC certifies the prior tipcut (road == prev.Index), not the tipcut + // being built (View.Index). buildProposal clears road >= view. + p := types.NewAppProposal( + cqc.GlobalRange().Next-1, + cqc.Proposal().Index(), + types.GenAppHash(rng), + cqc.Proposal().EpochIndex(), + ) appQC = utils.Some(TestAppQC(keys, p)) } cqc := types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) From 93ebd40d72c3fe92dd8b2dc4dd75f1410f2460e6 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 10:00:48 -0700 Subject: [PATCH 39/85] fix(autobahn): prune-leash epoch 0 seal; harden tip restart paths (CON-358) Peer PushCommitQC can leave Current>0 without an AppQC anchor if seal 0 is exempt; also fail closed on missing data tip QC and document avail-behind-cons recovery. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner_test.go | 4 ++ .../internal/autobahn/avail/state.go | 24 +++++++--- .../internal/autobahn/avail/state_test.go | 46 +++++++++++++++++++ .../internal/autobahn/consensus/state.go | 12 ++++- .../internal/autobahn/data/state.go | 8 +++- .../internal/autobahn/data/state_test.go | 15 ++++++ .../internal/autobahn/epoch/registry.go | 12 +++-- 7 files changed, 106 insertions(+), 15 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 0db7f1b4dd..5626b335de 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -121,10 +121,14 @@ func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { duo := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(m))) require.Equal(t, m, duo.Current.EpochIndex()) + // Prune-leashing seal of 0 means Current > 0 always has an AppQC anchor; + // missing one on restart is a hard fail (do not weaken that contract). _, err := newInner(registry, duo, utils.Some(&loadedAvailState{})) require.Error(t, err) + require.Contains(t, err.Error(), "prune anchor required") _, err = newInner(registry, duo, utils.None[*loadedAvailState]()) require.Error(t, err) + require.Contains(t, err.Error(), "prune anchor required") } func TestNewInnerLoadedBlocksContiguous(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index b95e011348..b5fa7e7bea 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -340,8 +340,16 @@ func (s *State) waitPruneLeash(ctx context.Context, epochIdx types.EpochIndex, i panic("unreachable") } -// waitCommitEpochLeashes gates seal of epoch N>0 (last road only). Epoch 0 and -// mid-epoch admits are exempt — see Registry invariants (prune/execution leash). +// waitCommitEpochLeashes gates seal of epoch N (last road only). Mid-epoch +// admits are exempt — see Registry invariants. +// +// Epoch 0 is not special-cased. Seal is {∅,0}→{0,1} (no Prev drop), but the +// prune leash still runs so leaving 0 always writes an AppQC anchor for +// newInner (Current>0 requires one). Do not reintroduce an epochIdx==0 skip: +// BlocksPerLane only caps local production; peers can PushCommitQC LastRoad(0) +// (then mid-epoch-1 QCs) with no local AppQC, which would otherwise restart +// without an anchor. +// // incoming: AppQC on PushAppQC (None for PushCommitQC). func (s *State) waitCommitEpochLeashes( ctx context.Context, @@ -349,7 +357,7 @@ func (s *State) waitCommitEpochLeashes( closingEpoch bool, incoming utils.Option[*types.AppQC], ) error { - if epochIdx == 0 || !closingEpoch { + if !closingEpoch { return nil } if err := s.waitPruneLeash(ctx, epochIdx, incoming); err != nil { @@ -457,8 +465,9 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]) error { idx := v.Msg().Proposal().RoadIndex() // Authenticate before waitForCommitQC — a far-future RoadIndex on an - // unverified vote would otherwise park this goroutine until ctx cancel - // (same stall shape PushAppQC rejects before admitRoadOrDrop). + // unverified vote would otherwise park this goroutine until ctx cancel. + // PushCommitQC/PushAppQC intentionally wait via admitRoadOrDrop instead; + // votes use Registry.EpochAt because they are not duo-gated the same way. ep, err := s.data.Registry().EpochAt(idx) if err != nil { return fmt.Errorf("EpochAt(%d): %w", idx, err) @@ -525,8 +534,9 @@ 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. + // Reject mismatched pairs before waiting. Ahead-of-window roads still wait + // in admitRoadOrDrop (intentional backpressure); these checks only catch + // inconsistent pairs. if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 2f6c38b9f3..5b06a0fb04 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1373,6 +1373,52 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { } } +// TestPushCommitQCEpoch0SealWaitsForAppQC: no epoch-0 exemption — seal waits +// for AppQC even though {∅,0}→{0,1} drops no Prev (anchor before Current>0). +func TestPushCommitQCEpoch0SealWaitsForAppQC(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + registry.EnsureEpoch(1) // exec leash for sealing 0 + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0) - 1, + }))), + }) + qcLast := makeCommitQC(ep0, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(0), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commitQCs.first = epoch.LastRoad(0) + inner.commitQCs.next = epoch.LastRoad(0) + } + state.markCommitQCsPersisted(prevOnLast) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushCommitQC(ctx, qcLast), context.Canceled) + + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + 0, 0, types.GenAppHash(rng), 0))) + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC0) + inner.commitQCs.first = epoch.LastRoad(0) + inner.commitQCs.next = epoch.LastRoad(0) + } + + require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(0)+1, inner.commitQCs.next) + require.Equal(t, types.EpochIndex(1), inner.epochDuo.Load().Current.EpochIndex()) + } +} + // TestPushAppQCBoundaryIncomingAppQC: tipcut closing M may carry the first // AppQC in M; count it as incoming before prune so catch-up does not deadlock. func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 9faa342ea6..5285eadd65 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -138,14 +138,22 @@ func newState( // Avail admits CommitQCs before consensus tip catches up via LastCommitQC. // Restart: avail tipcut must be >= consensus tipcut. Consensus may still // lag data; Run() catch-up from avail closes that gap. + // + // avail < cons is not auto-repaired (see ErrAvailBehindConsensus). 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: avail CommitQC tipcut < consensus tipcut on restart -// (consensus tip only advances from avail). +// ErrAvailBehindConsensus: avail CommitQC tipcut < consensus tipcut on restart. +// Consensus tip only advances from avail, so this should not occur after a clean +// crash (avail WAL is written before consensus runOutputs persist). +// +// A torn avail CommitQC WAL tail can leave avail one road behind a still-intact +// consensus inner file. There is no clamp: treat it as corrupt local state. +// Recovery: restore both layers from a consistent backup, or wipe +// PersistentStateDir (avail + consensus) and resync — do not delete only one side. var ErrAvailBehindConsensus = errors.New("avail CommitQC tip behind consensus tip") // CommitTipCut is the consensus view tipcut (NextIndexOpt of persisted CommitQC). diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 19ec3ea26c..8519fd3794 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -212,9 +212,13 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { for in := range s.inner.Lock() { initRoad := types.RoadIndex(0) if in.nextQC > in.first { - if lastQC := in.qcs[in.nextQC-1]; lastQC != nil { - initRoad = lastQC.QC().Proposal().Index() + 1 + n := in.nextQC - 1 + lastQC := in.qcs[n] + if lastQC == nil { + // Same invariant as CommitTipCut: nextQC claims a retained QC. + return nil, fmt.Errorf("init epochDuo: missing QC at global %d (first=%d nextQC=%d)", n, in.first, in.nextQC) } + initRoad = lastQC.QC().Proposal().Index() + 1 } initDuo, err := cfg.Registry.DuoAt(initRoad) if err != nil { diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index d0495920d1..1e99149e00 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -846,3 +846,18 @@ func TestTryBlockHidesGapFills(t *testing.T) { require.NoError(t, err) require.Equal(t, blocks2[last-gr2.First], got) } + +// TestCommitTipCutRejectsMissingTipQC: nextQC > first with a nil tip slot is an +// invariant break (same check NewState uses when centering epochDuo). +func TestCommitTipCutRejectsMissingTipQC(t *testing.T) { + rng := utils.TestRng() + registry, _, _ := epoch.GenRegistry(rng, 3) + state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) + for inner := range state.inner.Lock() { + inner.nextQC = inner.first + 1 + delete(inner.qcs, inner.nextQC-1) + } + _, err := state.CommitTipCut() + require.Error(t, err) + require.Contains(t, err.Error(), "missing QC") +} diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 07cbc8751a..eae147275e 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -37,10 +37,14 @@ type registryState struct { // Invariants: // - Independent of each layer's live EpochDuo (Prev|Current). Duo admits // traffic; the registry may retain more epochs for restart and leashes. -// - Execution cannot pass commit. Sealing epoch N (N>0) requires registry -// N+1 (execution leash) and AppQC covering N-1 before Prev is dropped -// (prune leash). Seal of epoch 0 is not prune-leashed: {∅,0}→{0,1} drops -// nothing. Finishing LastRoad(N-1) seeds epoch N+1 (AdvanceIfNeeded). +// - Execution cannot pass commit. Sealing epoch N (including 0) requires +// registry N+1 (execution leash) and AppQC in epoch N before the window +// slides (prune leash). Epoch 0 is intentionally not exempt: even though +// {∅,0}→{0,1} drops no Prev, the leash is what guarantees an AppQC anchor +// before Current leaves 0 (newInner hard-fails without one). Peer +// PushCommitQC can seal LastRoad(0) without local BlocksPerLane pressure, +// so "unreachable under BlocksPerLane" is not a valid exemption. Finishing +// LastRoad(N-1) seeds epoch N+1 (AdvanceIfNeeded). // - data/ is the sole restart seeder (SetupInitialDuo). Avail/consensus must // not seed; tip into an unseeded epoch → EpochAt/DuoAt hard-fail. // - Post-construction tipcuts: avail ≥ consensus. Consensus and avail may From 1ad962fff3befceb92e5be6feb2ab68ffceff138 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 10:20:53 -0700 Subject: [PATCH 40/85] fix(blocksim): stamp tipcut AppQC with prior road (CON-358) Match TestCommitQC: buildProposal clears AppQC when road >= view. Co-authored-by: Cursor --- .../block/blocksim/block_generator.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/sei-db/ledger_db/block/blocksim/block_generator.go b/sei-db/ledger_db/block/blocksim/block_generator.go index 3790250cff..b9f1a3c35f 100644 --- a/sei-db/ledger_db/block/blocksim/block_generator.go +++ b/sei-db/ledger_db/block/blocksim/block_generator.go @@ -170,13 +170,18 @@ func (g *BlockGenerator) buildFullCommitQC() (*types.FullCommitQC, []*types.Bloc viewSpec := types.ViewSpec{CommitQC: prev, Epochs: types.NewEpochDuo(types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0), utils.None[*types.Epoch]())} 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()) - return utils.Some(g.fakeAppQC(p)) - } - return utils.None[*types.AppQC]() - }() + // AppQC certifies the prior tipcut (road == prev.Index), not the tipcut + // being built (View.Index). buildProposal clears road >= view. + var appQC utils.Option[*types.AppQC] + if cqc, ok := prev.Get(); ok { + p := types.NewAppProposal( + cqc.GlobalRange().Next-1, + cqc.Proposal().Index(), + types.AppHash(g.rand.Bytes(hashSizeBytes)), + cqc.Proposal().EpochIndex(), + ) + appQC = utils.Some(g.fakeAppQC(p)) + } proposal := utils.OrPanic1(types.NewProposalForTesting( committee, viewSpec, From 3e07f64a65db69b7169f9f49d667f64fe70714b5 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 10:43:42 -0700 Subject: [PATCH 41/85] fix(autobahn): align future-AppQC fallback with sibling guards (CON-358) Fall back to prior CommitQC App instead of None; require Prev for Current-1; clarify PushAppVote EpochAt only bounds registry lookahead. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 15 ++++--- .../autobahn/types/proposal_test.go | 43 +++++++++++++++++++ .../internal/autobahn/avail/state.go | 7 ++- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 4fdf197d15..1eadd4787b 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -356,10 +356,11 @@ func buildProposal( app = old appQC = utils.None[*AppQC]() } - // If the new appProposal is from the future (which may happen if this node is behind), then clear appQC. - // The proposal will be useless in this case, but at least it will be valid. + // If the new appProposal is from the future (which may happen if this node is + // behind), drop it and fall back to the previous CommitQC's app — same as the + // road/epoch guards below — so Verify never sees App < previous CommitQC. if a, ok := app.Get(); ok && a.GlobalNumber() >= viewSpec.NextGlobalBlock() { - app = utils.None[*AppProposal]() + app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } // AppQC must be for a prior tipcut (road < view). Same-road or ahead can show up @@ -368,11 +369,13 @@ func buildProposal( app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } - // Tipcut AppQC may lag one epoch (Current or Current-1). Outside that window, - // fall back to the previous CommitQC's app so the tipcut stays valid. + // Tipcut AppQC may lag one epoch (Current or Current-1 with Prev present). + // Outside that window, fall back to the previous CommitQC's app. if a, ok := app.Get(); ok { appEp, cur := a.EpochIndex(), viewSpec.Epoch().EpochIndex() - if appEp != cur && (cur == 0 || appEp != cur-1) { + keep := appEp == cur || + (cur > 0 && appEp == cur-1 && viewSpec.Epochs.Prev.IsPresent()) + if !keep { app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index d74a1089b0..2e07ee1119 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -716,6 +716,49 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { require.NoError(t, fpWrong.Verify(vs)) } +func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + const split RoadIndex = 1 + prev := NewEpoch(0, RoadRange{First: 0, Next: split}, time.Time{}, committee, 1) + current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 1) + qc0 := BuildCommitQC(prev, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + // Same-epoch App on the justifying tipcut so BuildCommitQC keeps it (Prev absent there). + priorApp := makeAppQCFor(keys, qc0.GlobalRange().Next-1, qc0.Proposal().Index(), GenAppHash(rng), 1) + qc1 := BuildCommitQC(current, keys, utils.Some(qc0), nil, utils.Some(priorApp)) + require.True(t, qc1.Proposal().App().IsPresent(), "fixture CommitQC must carry App") + vs := ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(current, utils.Some(prev))} + leader := leaderKey(committee, keys, vs.View()) + lanes := oneLaneQCMap(rng, committee, keys, vs) + + future := makeAppQCFor(keys, vs.NextGlobalBlock(), vs.View().Index-1, GenAppHash(rng), 1) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(future))) + require.False(t, fp.appQC.IsPresent()) + app, ok := fp.Proposal().Msg().App().Get() + require.True(t, ok, "must fall back to CommitQC App, not clear to None") + want, _ := qc1.Proposal().App().Get() + require.Equal(t, want.AppHash(), app.AppHash()) + require.NoError(t, fp.Verify(vs)) +} + +func TestProposalClearsCurrentMinus1AppQCWithoutPrev(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + // Current > 0 but Prev absent (test helper shape). Current-1 AppQC must clear. + current := NewEpoch(1, RoadRange{First: 1, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 1) + qc0 := BuildCommitQC(NewEpoch(0, RoadRange{First: 0, Next: 1}, time.Time{}, committee, 1), + keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + qc1 := BuildCommitQC(current, keys, utils.Some(qc0), nil, utils.None[*AppQC]()) + vs := ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(current, utils.None[*Epoch]())} + leader := leaderKey(committee, keys, vs.View()) + lanes := oneLaneQCMap(rng, committee, keys, vs) + + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, + utils.Some(makeAppQCFor(keys, 0, 0, GenAppHash(rng), 0)))) + require.False(t, fp.appQC.IsPresent()) + require.NoError(t, fp.Verify(vs)) +} + func TestProposalClearsAppQCRoadNotBeforeView(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index b5fa7e7bea..816a920e83 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -466,8 +466,11 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] idx := v.Msg().Proposal().RoadIndex() // Authenticate before waitForCommitQC — a far-future RoadIndex on an // unverified vote would otherwise park this goroutine until ctx cancel. - // PushCommitQC/PushAppQC intentionally wait via admitRoadOrDrop instead; - // votes use Registry.EpochAt because they are not duo-gated the same way. + // EpochAt only rejects roads outside the registry; placeholder epochs + // (SetupInitialDuo / AdvanceIfNeeded lookahead) still admit a signed vote + // that then waits. PushCommitQC/PushAppQC intentionally wait via + // admitRoadOrDrop instead; votes use Registry.EpochAt because they are not + // duo-gated the same way. ep, err := s.data.Registry().EpochAt(idx) if err != nil { return fmt.Errorf("EpochAt(%d): %w", idx, err) From 59dd31ab4cae5c6c3419764dc87f0c684c68853f Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 26 Jul 2026 11:27:46 -0700 Subject: [PATCH 42/85] docs(autobahn): clarify QC admit-then-verify; Debug stale drops (CON-358) Keep PushCommitQC/PushAppQC on admit-before-verify (not EpochAt); quiet boundary chat from stale-drop Info logs. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/state.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 816a920e83..a156ba7545 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -303,7 +303,8 @@ func (s *State) admitRoadOrDrop( return utils.None[types.EpochDuo](), err } if !duoOpt.IsPresent() { - logger.Info("dropping stale "+what+": road behind window", + // Debug: Info is too chatty at epoch boundaries (many peers a road behind). + logger.Debug("dropping stale "+what+": road behind window", slog.Uint64("road", uint64(roadIdx)), "duo", s.epochDuo.Load().String()) return utils.None[types.EpochDuo](), nil } @@ -410,6 +411,9 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi // PushCommitQC admits qc for Current only (too early waits; stale drops), // then tip-interlock leashes when sealing (waitCommitEpochLeashes). +// +// Admit-then-verify is intentional backpressure for ahead-of-window QCs. +// Do not add EpochAt-before-wait — that is PushAppVote's path only. func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() if idx > 0 { @@ -530,6 +534,9 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] // PushAppQC requires a justifying CommitQC; tipcut insert uses the same // sealing leashes as PushCommitQC. +// +// Same admit-then-verify as PushCommitQC. Do not mirror PushAppVote's +// EpochAt-before-wait — pair checks below do not bound how far ahead idx may be. 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() { @@ -537,9 +544,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } } - // Reject mismatched pairs before waiting. Ahead-of-window roads still wait - // in admitRoadOrDrop (intentional backpressure); these checks only catch - // inconsistent pairs. + // Pair consistency only; ahead-of-window still waits in admitRoadOrDrop. if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) } From e9a8667cf2d8f290b267646bd7523919231b7c54 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 16:20:39 -0700 Subject: [PATCH 43/85] Filter tipcut AppQC to the propose epoch duo window. Proposers use LastAppQCInEpochDuo so out-of-window AppQCs are omitted and the prior CommitQC App is kept, without waiting on AppQC readiness. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 12 ++++++------ sei-tendermint/autobahn/types/proposal_test.go | 2 +- sei-tendermint/internal/autobahn/avail/state.go | 15 +++++++++++++++ .../internal/autobahn/consensus/state.go | 2 +- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 1eadd4787b..0a517bc4a4 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -126,7 +126,7 @@ func (v View) Next() View { } // ViewSpec is the local context for starting a view: justification QCs plus a -// Prev|Current EpochDuo. Tipcut AppQC may be Current or Current-1 (Prev lag). +// Prev|Current EpochDuo. Attached AppQC may be Current or Current-1 (Prev lag). type ViewSpec struct { // WARNING: currently we have implicit assumption that // TimeoutQC.View().Index == CommitQC.Index.Next(), @@ -357,8 +357,8 @@ func buildProposal( appQC = utils.None[*AppQC]() } // If the new appProposal is from the future (which may happen if this node is - // behind), drop it and fall back to the previous CommitQC's app — same as the - // road/epoch guards below — so Verify never sees App < previous CommitQC. + // behind), drop it and fall back to the previous CommitQC's app so Verify + // never sees App < previous CommitQC. if a, ok := app.Get(); ok && a.GlobalNumber() >= viewSpec.NextGlobalBlock() { app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() @@ -369,8 +369,8 @@ func buildProposal( app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } - // Tipcut AppQC may lag one epoch (Current or Current-1 with Prev present). - // Outside that window, fall back to the previous CommitQC's app. + // AppQC must be Current or Current-1 (Prev present). Outside that window, + // drop the candidate and keep the prior CommitQC App (no new appQC). if a, ok := app.Get(); ok { appEp, cur := a.EpochIndex(), viewSpec.Epoch().EpochIndex() keep := appEp == cur || @@ -435,7 +435,7 @@ func (m *FullProposal) TimeoutQC() utils.Option[*TimeoutQC] { } // Verify verifies the FullProposal against the current view. -// Tipcut AppQC may be Current or Current-1 (Prev lag for unfinished AppQC). +// Attached AppQC may be Current or Current-1 (Prev lag for unfinished AppQC). func (m *FullProposal) Verify(vs ViewSpec) error { c := vs.Epoch().Committee() return scope.Parallel(func(s scope.ParallelScope) error { diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 2e07ee1119..916a66a4be 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -710,7 +710,7 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { require.True(t, ok) require.Equal(t, EpochIndex(0), appPrev.EpochIndex()) - // AppQC outside {Current, Current-1} — cleared by buildProposal. + // AppQC outside {Current, Current-1} — cleared; tipcut keeps CommitQC App. fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2)))) require.False(t, fpWrong.appQC.IsPresent()) require.NoError(t, fpWrong.Verify(vs)) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index a156ba7545..99638f4897 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -376,6 +376,21 @@ func (s *State) LastAppQC() utils.Option[*types.AppQC] { panic("unreachable") } +// LastAppQCInEpochDuo returns LastAppQC when its epoch is usable for a tipcut +// in want (want or want-1). Otherwise None so the proposer omits a new AppQC +// and keeps the prior CommitQC App — including when AppQC is ahead of want. +func (s *State) LastAppQCInEpochDuo(want types.EpochIndex) utils.Option[*types.AppQC] { + appQC, ok := s.LastAppQC().Get() + if !ok { + return utils.None[*types.AppQC]() + } + appEp := appQC.Proposal().EpochIndex() + if appEp == want || (want > 0 && appEp == want-1) { + return utils.Some(appQC) + } + return utils.None[*types.AppQC]() +} + // WaitForAppQC waits until there is an AppQC for the given index or higher. // Returns this AppQC and the corresponding CommitQC. // Together they provide enough information to prune the availability state. diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 5285eadd65..f4b86e9a65 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -286,7 +286,7 @@ func (s *State) runPropose(ctx context.Context) error { vs, time.Now(), laneQCsMap, - s.avail.LastAppQC(), + s.avail.LastAppQCInEpochDuo(vs.Epoch().EpochIndex()), ) if err != nil { return fmt.Errorf("s.avail.WaitForProposal(): %w", err) From 9dfd7d8f1daa686d54387ddce6ef83c12ed59dca Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 16:23:22 -0700 Subject: [PATCH 44/85] Enforce EpochDuo Prev iff Current>0 and tighten AppQC epoch checks. NewEpochDuo panics when Prev presence disagrees with Current; Verify uses appEpoch+1!=cur and panics if Prev is missing for a Current-1 AppQC. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 15 ++- .../autobahn/types/epoch_duo_test.go | 32 +++++- sei-tendermint/autobahn/types/proposal.go | 12 +-- .../autobahn/types/proposal_test.go | 99 ++++++++----------- sei-tendermint/autobahn/types/testonly.go | 30 +++++- 5 files changed, 112 insertions(+), 76 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 728fafcb2b..0ab8ef298c 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -15,13 +15,20 @@ type EpochDuo struct { Current *Epoch } -// NewEpochDuo builds a Prev|Current window. Panics if Prev is present but not +// NewEpochDuo builds a Prev|Current window. Panics unless +// prev≠None ⇔ current.EpochIndex()>0, and when Prev is present it must be // contiguous with Current. func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { - if p, ok := prev.Get(); ok { - if want := current.EpochIndex(); p.EpochIndex()+1 != want { + cur := current.EpochIndex() + p, hasPrev := prev.Get() + if hasPrev != (cur > 0) { + panic(fmt.Sprintf("NewEpochDuo: Prev present=%v but Current epoch %d (want Prev iff Current>0)", + hasPrev, cur)) + } + if hasPrev { + if p.EpochIndex()+1 != cur { panic(fmt.Sprintf("NewEpochDuo: Prev epoch %d not contiguous with Current %d", - p.EpochIndex(), want)) + p.EpochIndex(), cur)) } if got, want := p.RoadRange().Next, current.RoadRange().First; got != want { panic(fmt.Sprintf("NewEpochDuo: Prev roads end at %d, Current starts at %d", got, want)) diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 9b5f2dd701..19f23428a7 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -50,10 +50,35 @@ func TestNewEpochDuo_PanicsOnNonContiguousRoads(t *testing.T) { _ = types.NewEpochDuo(current, utils.Some(prev)) } +func TestNewEpochDuo_PanicsOnPrevCurrentMismatch(t *testing.T) { + prev, current := testDuoEpochs(t) + t.Run("prev_absent_with_current_gt_0", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + _ = types.NewEpochDuo(current, utils.None[*types.Epoch]()) + }) + t.Run("prev_present_with_epoch_0", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + _ = types.NewEpochDuo(prev, utils.Some(prev)) + }) +} + func TestEpochForRoad(t *testing.T) { prev, current := testDuoEpochs(t) + rng := utils.TestRng() + weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} + committee := utils.OrPanic1(types.NewCommittee(weights)) + // Prev absent only for epoch 0. + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 100}, utils.GenTimestamp(rng), committee, 1) - currentOnly := types.NewEpochDuo(current, utils.None[*types.Epoch]()) + currentOnly := types.NewEpochDuo(ep0, utils.None[*types.Epoch]()) withPrev := types.NewEpochDuo(current, utils.Some(prev)) for _, tc := range []struct { @@ -63,9 +88,8 @@ func TestEpochForRoad(t *testing.T) { want *types.Epoch err error }{ - {"current_hit", currentOnly, 150, current, nil}, - {"current_before", currentOnly, 50, nil, types.ErrRoadBeforeWindow}, - {"current_after", currentOnly, 999, nil, types.ErrRoadAfterWindow}, + {"ep0_hit", currentOnly, 50, ep0, nil}, + {"ep0_after", currentOnly, 100, nil, types.ErrRoadAfterWindow}, {"prev_hit", withPrev, 50, prev, nil}, {"duo_current_hit", withPrev, 150, current, nil}, {"duo_after", withPrev, 200, nil, types.ErrRoadAfterWindow}, diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 0a517bc4a4..df8757fdf1 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -369,13 +369,11 @@ func buildProposal( app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } - // AppQC must be Current or Current-1 (Prev present). Outside that window, - // drop the candidate and keep the prior CommitQC App (no new appQC). + // AppQC must be Current or Current-1. Outside that window, drop the + // candidate and keep the prior CommitQC App (no new appQC). if a, ok := app.Get(); ok { appEp, cur := a.EpochIndex(), viewSpec.Epoch().EpochIndex() - keep := appEp == cur || - (cur > 0 && appEp == cur-1 && viewSpec.Epochs.Prev.IsPresent()) - if !keep { + if appEp != cur && appEp+1 != cur { app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } @@ -526,7 +524,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { appEpoch := app.EpochIndex() cur := vs.Epoch().EpochIndex() // Allow Current or Current-1 (Prev lag). Reject anything else. - if appEpoch != cur && (cur == 0 || appEpoch != cur-1) { + if appEpoch != cur && appEpoch+1 != cur { return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEpoch, cur) } appQC, ok := m.appQC.Get() @@ -541,7 +539,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { if appEpoch != cur { prev, ok := vs.Epochs.Prev.Get() if !ok { - return fmt.Errorf("appQC epoch %d needs Prev, but Prev is absent", appEpoch) + panic(fmt.Sprintf("appQC epoch %d needs Prev, but Prev is absent (Current %d)", appEpoch, cur)) } ep = prev } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 916a66a4be..365c660736 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -8,12 +8,11 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) -// genFreshEpoch returns an epoch whose road range is OpenRoadRange (so road index 0 -// is always valid for a ViewSpec with no CommitQC) but whose epoch index and first -// block are randomised to prevent tests from silently passing on zero-value defaults. +// genFreshEpoch returns epoch 0 with OpenRoadRange (road 0 valid for a ViewSpec +// with no CommitQC). firstBlock is randomised so tests do not pass on zeros. func genFreshEpoch(rng utils.Rng, committee *Committee) *Epoch { return NewEpoch( - GenEpochIndex(rng), + 0, OpenRoadRange(), time.Time{}, committee, @@ -95,7 +94,7 @@ func TestProposalVerifyRejectsEmptyTipcut(t *testing.T) { rng := utils.TestRng() committee, _ := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} // Direct Proposal.Verify rejects empty tipcuts (no LaneQCs / zero GlobalRange). // Local propose waits via WaitForLaneQCs; verification must refuse empty tipcuts @@ -117,7 +116,7 @@ func TestProposalVerifyFreshWithBlocks(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) // Produce a LaneQC for the proposer's lane. @@ -133,7 +132,7 @@ func TestNewProposalRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testing. rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -153,7 +152,7 @@ func TestProposalBlockTimestampStrictlyMonotone(t *testing.T) { committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) firstBlock := ep.FirstBlock() - vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() @@ -176,7 +175,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), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: EpochDuoForTest(ep)} proposer1 := leaderKey(committee, keys, vs1.View()) secondProposal := utils.OrPanic1(NewProposal( @@ -200,8 +199,8 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) genesisTimestamp := time.Now() - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), genesisTimestamp, committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + ep := NewEpoch(0, OpenRoadRange(), genesisTimestamp, committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} k := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) require.NoError(t, fp.Verify(vs)) @@ -215,7 +214,7 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() lQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -233,8 +232,8 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { utils.None[*AppQC](), )) - vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} - vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epochs: EpochDuoForTest(ep)} + vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epochs: EpochDuoForTest(ep)} proposer1 := leaderKey(committee, keys, vs1a.View()) fp1a := utils.OrPanic1(NewProposal( @@ -255,13 +254,13 @@ func TestProposalVerifyRejectsViewMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a valid proposal at genesis view (0, 0). - vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} leader0 := leaderKey(committee, keys, vs0.View()) 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) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epochs: EpochDuoForTest(ep)} err := fp.Verify(vs1) require.Error(t, err) } @@ -270,7 +269,7 @@ func TestProposalVerifyRejectsForgedSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) // Build two valid proposals with different timestamps. @@ -287,7 +286,7 @@ func TestProposalVerifyRejectsWrongProposer(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} correctLeader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -314,7 +313,7 @@ func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} // no timeoutQC + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} // no timeoutQC proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -340,7 +339,7 @@ func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -372,7 +371,7 @@ func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -404,7 +403,7 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -436,7 +435,7 @@ func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -474,7 +473,7 @@ func TestProposalVerifyRejectsMissingLaneQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -496,7 +495,7 @@ func TestProposalVerifyRejectsLaneQCBlockNumberMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -520,7 +519,7 @@ func TestProposalVerifyRejectsInvalidLaneQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -581,7 +580,7 @@ func makeFullProposal( appQC utils.Option[*AppQC], ) *FullProposal { committee := ep.Committee() - vs := ViewSpec{CommitQC: prev, Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{CommitQC: prev, Epochs: EpochDuoForTest(ep)} return utils.OrPanic1(NewProposal( leaderKey(committee, keys, vs.View()), vs, time.Now(), @@ -611,10 +610,10 @@ 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()) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: EpochDuoForTest(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), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epochs: EpochDuoForTest(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]()) @@ -627,7 +626,7 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} // no previous commitQC, so app starts at None + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} // no previous commitQC, so app starts at None leader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -741,24 +740,6 @@ func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { require.NoError(t, fp.Verify(vs)) } -func TestProposalClearsCurrentMinus1AppQCWithoutPrev(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - // Current > 0 but Prev absent (test helper shape). Current-1 AppQC must clear. - current := NewEpoch(1, RoadRange{First: 1, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 1) - qc0 := BuildCommitQC(NewEpoch(0, RoadRange{First: 0, Next: 1}, time.Time{}, committee, 1), - keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) - qc1 := BuildCommitQC(current, keys, utils.Some(qc0), nil, utils.None[*AppQC]()) - vs := ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(current, utils.None[*Epoch]())} - leader := leaderKey(committee, keys, vs.View()) - lanes := oneLaneQCMap(rng, committee, keys, vs) - - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, - utils.Some(makeAppQCFor(keys, 0, 0, GenAppHash(rng), 0)))) - require.False(t, fp.appQC.IsPresent()) - require.NoError(t, fp.Verify(vs)) -} - func TestProposalClearsAppQCRoadNotBeforeView(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) @@ -824,7 +805,7 @@ func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{Epochs: EpochDuoForTest(ep)} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -853,7 +834,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{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} leader0 := leaderKey(committee, keys, vs0.View()) lane := committee.Leader(vs0.View()) laneQC0 := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -874,7 +855,7 @@ func TestProposalVerifyValidReproposal(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuoForTest(ep)} require.Equal(t, View{Index: 0, Number: 1, EpochIndex: ep.EpochIndex()}, vs1.View()) leader1 := leaderKey(committee, keys, vs1.View()) @@ -891,7 +872,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -907,7 +888,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuoForTest(ep)} leader1 := leaderKey(committee, keys, vs1.View()) // Create a valid reproposal, then tamper it with unnecessary laneQCs. @@ -930,7 +911,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -946,7 +927,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuoForTest(ep)} leader1 := leaderKey(committee, keys, vs1.View()) // Build the valid reproposal, then tamper its timestamp to get a different hash. @@ -982,7 +963,7 @@ func TestProposalVerifyRejectsInvalidTimeoutQCSignature(t *testing.T) { } badTimeoutQC := NewTimeoutQC(timeoutVotes) - vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epochs: EpochDuoForTest(ep)} leader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -995,10 +976,10 @@ func TestViewSpecViewStampsEpochIndex(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) epochIdx := EpochIndex(7) - ep := NewEpoch(epochIdx, OpenRoadRange(), time.Time{}, committee, 0) + ep := NewEpoch(epochIdx, RoadRange{First: 1, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 0) // Without TimeoutQC: epoch index must come from vs.Epoch(). - vs0 := ViewSpec{Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} if got := vs0.View().EpochIndex; got != epochIdx { t.Fatalf("no-TimeoutQC path: EpochIndex = %d, want %d", got, epochIdx) } @@ -1007,7 +988,7 @@ func TestViewSpecViewStampsEpochIndex(t *testing.T) { tqc := NewTimeoutQC([]*FullTimeoutVote{ NewFullTimeoutVote(keys[0], View{EpochIndex: 0}, utils.None[*PrepareQC]()), }) - vs1 := ViewSpec{TimeoutQC: utils.Some(tqc), Epochs: NewEpochDuo(ep, utils.None[*Epoch]())} + vs1 := ViewSpec{TimeoutQC: utils.Some(tqc), Epochs: EpochDuoForTest(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 63b0d856e7..8d4f972211 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -12,6 +12,27 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// EpochDuoForTest builds a valid Prev|Current duo for tests. Prev is absent +// iff Current is epoch 0; otherwise a synthetic contiguous Prev is created. +func EpochDuoForTest(current *Epoch) EpochDuo { + if current.EpochIndex() == 0 { + return NewEpochDuo(current, utils.None[*Epoch]()) + } + first := current.RoadRange().First + if first == 0 { + panic(fmt.Sprintf("EpochDuoForTest: epoch %d with RoadRange.First==0 cannot host Prev", + current.EpochIndex())) + } + prev := NewEpoch( + current.EpochIndex()-1, + RoadRange{First: 0, Next: first}, + time.Time{}, + current.Committee(), + 1, + ) + return NewEpochDuo(current, utils.Some(prev)) +} + // BuildCommitQC builds a valid CommitQC from explicit lane QCs and an optional app QC. // If laneQCs is empty, a single 1-block LaneQC is synthesized so the tipcut is // non-empty (empty tipcuts are rejected by Proposal.Verify). @@ -23,7 +44,7 @@ func BuildCommitQC( laneQCs map[LaneID]*LaneQC, appQC utils.Option[*AppQC], ) *CommitQC { - vs := ViewSpec{CommitQC: prev, Epochs: NewEpochDuo(epoch, utils.None[*Epoch]())} + vs := ViewSpec{CommitQC: prev, Epochs: EpochDuoForTest(epoch)} if len(laneQCs) == 0 { laneQCs = oneBlockLaneQCMap(vs, keys) } @@ -291,10 +312,15 @@ func GenEpochIndex(rng utils.Rng) EpochIndex { // GenEpochWithCommittee returns a random Epoch wrapping committee. // epochIndex, firstBlock, timestamp, and Roads.First are randomized so that tests // exercise epoch-binding checks rather than silently passing on zero values. +// When epochIndex > 0, First is at least 1 so EpochDuoForTest can place Prev. func GenEpochWithCommittee(rng utils.Rng, committee *Committee) *Epoch { + idx := GenEpochIndex(rng) first := RoadIndex(rng.Uint64() % 1000) + if idx > 0 && first == 0 { + first = RoadIndex(rng.Uint64()%999) + 1 + } return NewEpoch( - GenEpochIndex(rng), + idx, RoadRange{First: first, Next: first + RoadIndex(rng.Uint64()%10000) + 11}, utils.GenTimestamp(rng), committee, From 3f63fcc137ad398b71b28e3c91d552c9c4bd0e70 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 16:31:09 -0700 Subject: [PATCH 45/85] =?UTF-8?q?Drop=20redundant=20road=E2=89=A5view=20Ap?= =?UTF-8?q?pQC=20clear=20in=20buildProposal.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Well-formed AppQCs with road ≥ view already fail the future GlobalNumber guard; malformed ones are left for Verify. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 9 +---- .../autobahn/types/proposal_test.go | 40 ++++++++----------- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index df8757fdf1..d1df84b305 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -358,17 +358,12 @@ func buildProposal( } // If the new appProposal is from the future (which may happen if this node is // behind), drop it and fall back to the previous CommitQC's app so Verify - // never sees App < previous CommitQC. + // never sees App < previous CommitQC. Well-formed AppQCs with road ≥ view + // have GlobalNumber ≥ NextGlobalBlock, so this also covers that case. if a, ok := app.Get(); ok && a.GlobalNumber() >= viewSpec.NextGlobalBlock() { app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } - // AppQC must be for a prior tipcut (road < view). Same-road or ahead can show up - // on restart/gossip races; drop it so the tipcut stays valid. - if a, ok := app.Get(); ok && a.RoadIndex() >= viewSpec.View().Index { - app = AppOpt(ProposalOpt(viewSpec.CommitQC)) - appQC = utils.None[*AppQC]() - } // AppQC must be Current or Current-1. Outside that window, drop the // candidate and keep the prior CommitQC App (no new appQC). if a, ok := app.Get(); ok { diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 365c660736..078fed9cce 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -740,7 +740,7 @@ func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { require.NoError(t, fp.Verify(vs)) } -func TestProposalClearsAppQCRoadNotBeforeView(t *testing.T) { +func TestProposalVerifyRejectsAppQCRoadNotBeforeView(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) vs := viewSpecContiguousDuo(keys, committee) @@ -748,34 +748,28 @@ func TestProposalClearsAppQCRoadNotBeforeView(t *testing.T) { view := vs.View().Index lanes := oneLaneQCMap(rng, committee, keys, vs) - // buildProposal clears AppQC at road == view (and ahead) so the tipcut stays valid. - fpSame := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(makeAppQCFor(keys, 0, view, GenAppHash(rng), 1)))) - require.False(t, fpSame.appQC.IsPresent()) - require.NoError(t, fpSame.Verify(vs)) - - fpAhead := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(makeAppQCFor(keys, 0, view+1, GenAppHash(rng), 1)))) - require.False(t, fpAhead.appQC.IsPresent()) - require.NoError(t, fpAhead.Verify(vs)) - // AppQC for the justifying CommitQC road (view-1) — kept and accepted. fpOk := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(makeAppQCFor(keys, 0, view-1, GenAppHash(rng), 1)))) require.True(t, fpOk.appQC.IsPresent()) require.NoError(t, fpOk.Verify(vs)) - // Verify still rejects a tipcut that bypasses buildProposal with same-road AppQC. - bad := makeAppQCFor(keys, 0, view, GenAppHash(rng), 1) - base := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.None[*AppQC]())) - baseMsg := base.proposal.Msg() - ranges := make([]*LaneRange, 0, len(baseMsg.laneRanges)) - for _, r := range baseMsg.laneRanges { - ranges = append(ranges, r) - } - tampered := &FullProposal{ - proposal: Sign(leader, newProposal(vs.View(), baseMsg.Timestamp(), ranges, utils.Some(bad.Proposal()), vs.NextGlobalBlock())), - laneQCs: base.laneQCs, - appQC: utils.Some(bad), + // Malformed same-road / ahead AppQC (global still in the past) is not + // sanitized by buildProposal; Verify rejects. + for _, road := range []RoadIndex{view, view + 1} { + bad := makeAppQCFor(keys, 0, road, GenAppHash(rng), 1) + base := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.None[*AppQC]())) + baseMsg := base.proposal.Msg() + ranges := make([]*LaneRange, 0, len(baseMsg.laneRanges)) + for _, r := range baseMsg.laneRanges { + ranges = append(ranges, r) + } + tampered := &FullProposal{ + proposal: Sign(leader, newProposal(vs.View(), baseMsg.Timestamp(), ranges, utils.Some(bad.Proposal()), vs.NextGlobalBlock())), + laneQCs: base.laneQCs, + appQC: utils.Some(bad), + } + require.Error(t, tampered.Verify(vs), "road %d", road) } - require.Error(t, tampered.Verify(vs)) } func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { From b6901f466f13f6a10b3a5665edf33144e4095f7c Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 16:31:56 -0700 Subject: [PATCH 46/85] Inline avail seal leashes; drop duplicate WaitForDuo. Only wait on AppQC and the next duo when admitting Current's last road, instead of a closingEpoch flag plus a second WaitForDuo. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 59 ++++++------------- 1 file changed, 17 insertions(+), 42 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 99638f4897..4f6ee22193 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -10,7 +10,6 @@ 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" @@ -313,6 +312,13 @@ func (s *State) admitRoadOrDrop( // waitPruneLeash blocks until latest AppQC is from epochIdx or later. // incoming, if present, counts before latestAppQC is updated. +// +// Called only when sealing epoch N (last road). Epoch 0 is not special-cased: +// seal is {∅,0}→{0,1} (no Prev drop), but the leash still runs so leaving 0 +// always writes an AppQC anchor for newInner (Current>0 requires one). Do not +// reintroduce an epochIdx==0 skip: BlocksPerLane only caps local production; +// peers can PushCommitQC LastRoad(0) (then mid-epoch-1 QCs) with no local +// AppQC, which would otherwise restart without an anchor. func (s *State) waitPruneLeash(ctx context.Context, epochIdx types.EpochIndex, incoming utils.Option[*types.AppQC]) error { for inner, ctrl := range s.inner.Lock() { ready := func() bool { @@ -341,33 +347,6 @@ func (s *State) waitPruneLeash(ctx context.Context, epochIdx types.EpochIndex, i panic("unreachable") } -// waitCommitEpochLeashes gates seal of epoch N (last road only). Mid-epoch -// admits are exempt — see Registry invariants. -// -// Epoch 0 is not special-cased. Seal is {∅,0}→{0,1} (no Prev drop), but the -// prune leash still runs so leaving 0 always writes an AppQC anchor for -// newInner (Current>0 requires one). Do not reintroduce an epochIdx==0 skip: -// BlocksPerLane only caps local production; peers can PushCommitQC LastRoad(0) -// (then mid-epoch-1 QCs) with no local AppQC, which would otherwise restart -// without an anchor. -// -// incoming: AppQC on PushAppQC (None for PushCommitQC). -func (s *State) waitCommitEpochLeashes( - ctx context.Context, - epochIdx types.EpochIndex, - closingEpoch bool, - incoming utils.Option[*types.AppQC], -) error { - if !closingEpoch { - return nil - } - if err := s.waitPruneLeash(ctx, epochIdx, incoming); err != nil { - return err - } - _, 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() { @@ -425,7 +404,7 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi } // PushCommitQC admits qc for Current only (too early waits; stale drops), -// then tip-interlock leashes when sealing (waitCommitEpochLeashes). +// then tip-interlock leashes when sealing (last road of Current). // // Admit-then-verify is intentional backpressure for ahead-of-window QCs. // Do not add EpochAt-before-wait — that is PushAppVote's path only. @@ -448,15 +427,13 @@ 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) } - closing := ep.RoadRange().IsLastRoad(idx) - if err := s.waitCommitEpochLeashes(ctx, ep.EpochIndex(), closing, utils.None[*types.AppQC]()); err != nil { - return err - } // Boundary: switch to the next epoch on Current's last CommitQC. - // Resolve next duo off-lock (WaitForDuo). nextDuo := utils.None[types.EpochDuo]() - if closing { + if ep.RoadRange().IsLastRoad(idx) { + if err := s.waitPruneLeash(ctx, ep.EpochIndex(), utils.None[*types.AppQC]()); err != nil { + return err + } nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) if err != nil { return err @@ -585,15 +562,13 @@ 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. + // Same seal leashes as PushCommitQC when this tipcut is Current's last road. // Pass this AppQC as incoming so a tipcut that first enters epoch N can close N. - closing := ep.RoadRange().IsLastRoad(idx) - 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. nextDuo := utils.None[types.EpochDuo]() - if closing { + if ep.RoadRange().IsLastRoad(idx) { + if err := s.waitPruneLeash(ctx, ep.EpochIndex(), utils.Some(appQC)); err != nil { + return err + } nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) if err != nil { return err From 814e954b0276b93fcae3db819d2d71bd9fa63fd3 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 16:33:08 -0700 Subject: [PATCH 47/85] Align PushAppVote with admit-then-verify parking. Drop EpochAt-before-wait so far-future AppVotes park like QCs; rename waitEpochForRoad to waitForEpoch. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 42 +++++++------------ .../internal/autobahn/avail/state_test.go | 19 ++++----- 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 4f6ee22193..8519ae2be5 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -257,8 +257,8 @@ func (s *State) waitRoadInWindow( return utils.None[types.EpochDuo](), nil } -// waitEpochForRoad: too early waits; behind window → None (stale). -func (s *State) waitEpochForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[types.EpochDuo], error) { +// waitForEpoch: too early waits until road is in Prev|Current; behind window → None (stale). +func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[types.EpochDuo], error) { return s.waitRoadInWindow(ctx, roadIdx, func(duo types.EpochDuo) utils.Option[*types.Epoch] { if ep, err := duo.EpochForRoad(roadIdx); err == nil { @@ -407,7 +407,6 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi // then tip-interlock leashes when sealing (last road of Current). // // Admit-then-verify is intentional backpressure for ahead-of-window QCs. -// Do not add EpochAt-before-wait — that is PushAppVote's path only. func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() if idx > 0 { @@ -458,32 +457,16 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { } // PushAppVote pushes an AppVote to the state. +// Same admit-then-verify as PushAppQC: far-future roads park until the duo +// and CommitQC tip catch up (one stream goroutine; does not block others). func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]) error { idx := v.Msg().Proposal().RoadIndex() - // Authenticate before waitForCommitQC — a far-future RoadIndex on an - // unverified vote would otherwise park this goroutine until ctx cancel. - // EpochAt only rejects roads outside the registry; placeholder epochs - // (SetupInitialDuo / AdvanceIfNeeded lookahead) still admit a signed vote - // that then waits. PushCommitQC/PushAppQC intentionally wait via - // admitRoadOrDrop instead; votes use Registry.EpochAt because they are not - // duo-gated the same way. - ep, err := s.data.Registry().EpochAt(idx) - if err != nil { - return fmt.Errorf("EpochAt(%d): %w", idx, err) - } - if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want { - return fmt.Errorf("appVote epoch_index %d, want %d", got, want) - } - committee := ep.Committee() - if err := v.VerifySig(committee); err != nil { - return fmt.Errorf("v.VerifySig(): %w", err) - } // A vote may arrive before its CommitQC advances the tip. if err := s.waitForCommitQC(ctx, idx); err != nil { return err } // Too-early roads (ahead of Prev|Current) backpressure; too-late are dropped. - duoOpt, err := s.admitRoadOrDrop(ctx, idx, "AppVote", s.waitEpochForRoad) + duoOpt, err := s.admitRoadOrDrop(ctx, idx, "AppVote", s.waitForEpoch) if err != nil { return err } @@ -491,8 +474,14 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if !ok { return nil } - ep = utils.OrPanic1(duo.EpochForRoad(idx)) - committee = ep.Committee() + ep := utils.OrPanic1(duo.EpochForRoad(idx)) + if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want { + return fmt.Errorf("appVote epoch_index %d, want %d", got, want) + } + committee := ep.Committee() + if err := v.VerifySig(committee); err != nil { + return fmt.Errorf("v.VerifySig(): %w", 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) { @@ -527,8 +516,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] // PushAppQC requires a justifying CommitQC; tipcut insert uses the same // sealing leashes as PushCommitQC. // -// Same admit-then-verify as PushCommitQC. Do not mirror PushAppVote's -// EpochAt-before-wait — pair checks below do not bound how far ahead idx may be. +// Same admit-then-verify as PushCommitQC. 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() { @@ -547,7 +535,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return fmt.Errorf("appQC GlobalNumber not in commitQC range") } idx := commitQC.Proposal().Index() - duoOpt, err := s.admitRoadOrDrop(ctx, idx, "AppQC", s.waitEpochForRoad) + duoOpt, err := s.admitRoadOrDrop(ctx, idx, "AppQC", s.waitForEpoch) 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 5b06a0fb04..9a0f382c1c 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1018,7 +1018,7 @@ func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { require.NoError(t, err) // AppVote for Current's first road while CommitQC tip is still behind: - // after VerifySig, PushAppVote blocks in waitForCommitQC (not on the epoch window). + // PushAppVote parks in waitForCommitQC (not on the epoch window). epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) proposal := types.NewAppProposal( epM.FirstBlock(), epM.RoadRange().First, types.GenAppHash(rng), epM.EpochIndex()) @@ -1029,9 +1029,9 @@ func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) } -// TestPushAppVoteUnregisteredRoadRejectsBeforeWait: a far-future RoadIndex whose -// epoch is not seeded must fail EpochAt immediately — not park in waitForCommitQC. -func TestPushAppVoteUnregisteredRoadRejectsBeforeWait(t *testing.T) { +// TestPushAppVoteFarFutureParks: an unregistered far-future RoadIndex parks +// (waitForCommitQC) rather than failing EpochAt up front. +func TestPushAppVoteFarFutureParks(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) ds := newTestDataState(&data.Config{Registry: registry}) @@ -1042,16 +1042,13 @@ func TestPushAppVoteUnregisteredRoadRejectsBeforeWait(t *testing.T) { proposal := types.NewAppProposal(0, far, types.GenAppHash(rng), m+10) vote := types.Sign(keys[0], types.NewAppVote(proposal)) ctx, cancel := context.WithCancel(t.Context()) - cancel() // would surface as Canceled if we waited first + cancel() - err = state.PushAppVote(ctx, vote) - require.Error(t, err) - require.NotErrorIs(t, err, context.Canceled) - require.Contains(t, err.Error(), "EpochAt") + require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) } // TestWaitCurrentForRoadPrevNotAdmitted: a road in Prev is too late for -// Current-only admission (CommitQC), even though waitEpochForRoad would +// Current-only admission (CommitQC), even though waitForEpoch would // still resolve it. func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { rng := utils.TestRng() @@ -1063,7 +1060,7 @@ func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { registerDuoAtEpoch(state, m) // Prev=M-1|Current=M roadInPrev := epoch.FirstRoad(m - 1) - duoWin, err := state.waitEpochForRoad(t.Context(), roadInPrev) + duoWin, err := state.waitForEpoch(t.Context(), roadInPrev) require.NoError(t, err) require.True(t, duoWin.IsPresent(), "Prev|Current window still covers Prev roads") From 9f265569896aa807bd9278883fd20d61f5255bb8 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 17:06:17 -0700 Subject: [PATCH 48/85] Keep one LaneQC per blockVotes; drop zero-weight on reweight. Cache qc at blockVotes, skip set.add once formed (still retain byKey for reweight), and nil-safe pointer queue lookups after the type change. Co-authored-by: Cursor --- .../internal/autobahn/avail/block_votes.go | 61 ++++++++++-------- .../autobahn/avail/block_votes_test.go | 62 ++++++++++++++----- .../internal/autobahn/avail/inner.go | 10 ++- .../internal/autobahn/avail/state.go | 10 +-- 4 files changed, 95 insertions(+), 48 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index dbbefb294f..0c31e006b9 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -9,37 +9,32 @@ import ( type laneVoteSet struct { weight uint64 votes []*types.Signed[*types.LaneVote] - qc utils.Option[*types.LaneQC] header *types.BlockHeader } func (s *laneVoteSet) reset() { s.weight = 0 s.votes = s.votes[:0] - s.qc = utils.None[*types.LaneQC]() } -func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { - if s.qc.IsPresent() { - return utils.None[*types.LaneQC]() - } +// add credits vote weight. Returns true if weight reached quorum (may already +// have been at/above quorum from a prior add). +func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) bool { s.weight += weight s.votes = append(s.votes, vote) - if s.weight < quorum { - return utils.None[*types.LaneQC]() - } - s.qc = utils.Some(types.NewLaneQC(s.votes)) - return s.qc + return s.weight >= quorum } // blockVotes credits lane votes under the Current committee only. +// At most one LaneQC is retained for the block (not one per header hash). type blockVotes struct { byKey map[types.PublicKey]*types.Signed[*types.LaneVote] byHash map[types.BlockHeaderHash]*laneVoteSet + qc utils.Option[*types.LaneQC] } -func newBlockVotes() blockVotes { - return blockVotes{ +func newBlockVotes() *blockVotes { + return &blockVotes{ byKey: map[types.PublicKey]*types.Signed[*types.LaneVote]{}, byHash: map[types.BlockHeaderHash]*laneVoteSet{}, } @@ -47,7 +42,7 @@ func newBlockVotes() blockVotes { // pushVote credits vote under ep (Current). Zero-weight → drop (not retained). // Callers VerifySig first; after a lock release, Weight==0 is still a silent drop. -func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { +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]() @@ -58,37 +53,51 @@ func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVot } bv.byKey[k] = vote + // One QC per block: still retain the vote for reweight, but stop growing sets. + if bv.qc.IsPresent() { + return utils.None[*types.LaneQC]() + } + h := vote.Msg().Header().Hash() set, ok := bv.byHash[h] if !ok { set = &laneVoteSet{header: vote.Msg().Header()} bv.byHash[h] = set } - return set.add(w, ep.Committee().LaneQuorum(), vote) + if !set.add(w, ep.Committee().LaneQuorum(), vote) { + return utils.None[*types.LaneQC]() + } + bv.qc = utils.Some(types.NewLaneQC(set.votes)) + return bv.qc } // reweight recomputes already-stored votes under new Current after advanceEpoch. -// Callers wake waiters via ctrl.Updated() after advanceEpoch (not via a return flag). -func (bv blockVotes) reweight(newEpoch *types.Epoch) { +// Zero-weight signers are removed from byKey. Callers wake waiters via +// ctrl.Updated() after advanceEpoch (not via a return flag). +func (bv *blockVotes) reweight(newEpoch *types.Epoch) { c := newEpoch.Committee() + bv.qc = utils.None[*types.LaneQC]() for _, set := range bv.byHash { set.reset() } for k, vote := range bv.byKey { w := c.Weight(k) if w == 0 { + delete(bv.byKey, k) continue } - set := bv.byHash[vote.Msg().Header().Hash()] - set.add(w, c.LaneQuorum(), vote) + h := vote.Msg().Header().Hash() + set, ok := bv.byHash[h] + if !ok { + set = &laneVoteSet{header: vote.Msg().Header()} + bv.byHash[h] = set + } + if set.add(w, c.LaneQuorum(), vote) && !bv.qc.IsPresent() { + bv.qc = utils.Some(types.NewLaneQC(set.votes)) + } } } -func (bv blockVotes) laneQC() utils.Option[*types.LaneQC] { - for _, set := range bv.byHash { - if set.qc.IsPresent() { - return set.qc - } - } - return utils.None[*types.LaneQC]() +func (bv *blockVotes) laneQC() utils.Option[*types.LaneQC] { + return bv.qc } diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 90d76c69bd..7fa280d52f 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -25,28 +25,22 @@ func TestLaneVoteSet_Add(t *testing.T) { } set := &laneVoteSet{} - require.False(t, set.add(1, 2, mkVote()).IsPresent()) + require.False(t, set.add(1, 2, mkVote())) require.Equal(t, uint64(1), set.weight) require.Len(t, set.votes, 1) - require.False(t, set.qc.IsPresent()) - qc, ok := set.add(1, 2, mkVote()).Get() - require.True(t, ok) - got, ok := set.qc.Get() - require.True(t, ok) - require.Equal(t, got, qc) + require.True(t, set.add(1, 2, mkVote())) require.Equal(t, uint64(2), set.weight) require.Len(t, set.votes, 2) - require.False(t, set.add(1, 2, mkVote()).IsPresent()) - require.Equal(t, uint64(2), set.weight) - require.Len(t, set.votes, 2) + require.True(t, set.add(1, 2, mkVote())) + require.Equal(t, uint64(3), set.weight) + require.Len(t, set.votes, 3) heavy := &laneVoteSet{} - require.True(t, heavy.add(3, 2, mkVote()).IsPresent()) + require.True(t, heavy.add(3, 2, mkVote())) require.Equal(t, uint64(3), heavy.weight) require.Len(t, heavy.votes, 1) - require.True(t, heavy.qc.IsPresent()) } func TestPushVote_ZeroWeightNotRetained(t *testing.T) { @@ -92,13 +86,13 @@ func TestPushVote_CurrentCommitteeOnly(t *testing.T) { require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) qc, ok := bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).Get() require.True(t, ok) - gotQC, ok := bv.byHash[h].qc.Get() + gotQC, ok := bv.qc.Get() require.True(t, ok) require.Equal(t, qc, gotQC) // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; A+B still form quorum under new weights. bv.reweight(ep1) - require.True(t, bv.byHash[h].qc.IsPresent()) + require.True(t, bv.qc.IsPresent()) require.False(t, bv.pushVote(ep1, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) require.Contains(t, bv.byKey, keyE.Public()) require.Equal(t, header, bv.byHash[h].header) @@ -156,5 +150,43 @@ func TestPushVote_ReweightAfterAdvance(t *testing.T) { require.Equal(t, uint64(1), bv.byHash[h].weight) require.Len(t, bv.byHash[h].votes, 1) require.Equal(t, keyA.Public(), bv.byHash[h].votes[0].Key()) - require.True(t, bv.byHash[h].qc.IsPresent()) + require.True(t, bv.qc.IsPresent()) + require.NotContains(t, bv.byKey, keyB.Public(), "zero-weight signer removed from byKey") +} + +func TestPushVote_OneQCPerBlockVotes(t *testing.T) { + rng := utils.TestRng() + keyA := types.GenSecretKey(rng) + keyB := types.GenSecretKey(rng) + keyC := types.GenSecretKey(rng) + keyD := types.GenSecretKey(rng) + + // 4×1 → LaneQuorum=2. + ep := makeVoteEpoch(0, map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, + }) + + lane := keyA.Public() + header1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + header2 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + require.NotEqual(t, header1.Hash(), header2.Hash()) + + bv := newBlockVotes() + require.False(t, bv.pushVote(ep, types.Sign(keyA, types.NewLaneVote(header1))).IsPresent()) + require.True(t, bv.pushVote(ep, types.Sign(keyB, types.NewLaneVote(header1))).IsPresent()) + require.True(t, bv.qc.IsPresent()) + got, ok := bv.qc.Get() + require.True(t, ok) + require.Equal(t, header1.Hash(), got.Header().Hash()) + + // Competing hash is retained in byKey for reweight but must not grow byHash + // or replace the single QC. + require.False(t, bv.pushVote(ep, types.Sign(keyC, types.NewLaneVote(header2))).IsPresent()) + require.False(t, bv.pushVote(ep, types.Sign(keyD, types.NewLaneVote(header2))).IsPresent()) + require.Contains(t, bv.byKey, keyC.Public()) + require.Contains(t, bv.byKey, keyD.Public()) + require.NotContains(t, bv.byHash, header2.Hash()) + got, ok = bv.qc.Get() + require.True(t, ok) + require.Equal(t, header1.Hash(), got.Header().Hash()) } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index ed6be7979d..4f78891bd9 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -24,7 +24,7 @@ type inner struct { // laneState fields share the same lifecycle. type laneState struct { blocks *queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] - votes *queue[types.BlockNumber, blockVotes] + votes *queue[types.BlockNumber, *blockVotes] // nextBlockToPersist is reconstructed from persisted blocks on restart. // // TODO: consider giving this its own AtomicSend to avoid waking unrelated @@ -37,7 +37,7 @@ type laneState struct { func newLaneState() *laneState { return &laneState{ blocks: newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]](), - votes: newQueue[types.BlockNumber, blockVotes](), + votes: newQueue[types.BlockNumber, *blockVotes](), } } @@ -196,7 +196,11 @@ func verifyLoadedCommitQC(registry *epoch.Registry, qc *types.CommitQC) error { } func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { - return i.lanes[lane].votes.q[n].laneQC() + bv, ok := i.lanes[lane].votes.q[n] + if !ok { + return utils.None[*types.LaneQC]() + } + return bv.laneQC() } // advanceEpoch installs nextDuo at a boundary. Adds Current lanes; does not diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 8519ae2be5..2dd17a8322 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -740,10 +740,12 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc if q.first > lr.First() { return nil, types.ErrPruned } - if set, ok := q.q[n].byHash[want]; ok { - want = set.header.ParentHash() - headers[len(headers)-i-1] = set.header - break + if bv := q.q[n]; bv != nil { + if set, ok := bv.byHash[want]; ok { + want = set.header.ParentHash() + headers[len(headers)-i-1] = set.header + break + } } // Otherwise, wait. if err := ctrl.Wait(ctx); err != nil { From 50b19f054c265817f6b76f682c0d7876b6b4cf2f Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 17:14:21 -0700 Subject: [PATCH 49/85] Park PushVote until Current accepts the signer. Future-epoch lane voters wait for weight and lane membership before verify, matching admit-then-verify parking used for AppVote. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 32 ++++++--- .../internal/autobahn/avail/state_test.go | 66 ++++++++++++++++++- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 2dd17a8322..a4297fed73 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -624,9 +624,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()) } - // Snapshot Current once for off-lock verify. Unlike PushVote, we do not - // re-check membership after WaitUntil — lane proposals are not reweighted - // across epoch advances (genesis committees are stable today). + // Snapshot Current once for off-lock verify. Unlike PushVote (which parks + // until Current accepts the signer), we do not wait for future committees — + // lane proposals are not reweighted across epoch advances. duo := s.epochDuo.Load() c := duo.Current.Committee() if err := p.Msg().Verify(c); err != nil { @@ -676,19 +676,31 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos return nil } -// PushVote verifies against Current, then under lock waits for lane capacity and -// credits with the live duo (drop if Current advanced and signer left). +// PushVote parks until Current can accept the vote (signer weight + voted lane), +// verifies, then under lock waits for capacity and credits with the live duo +// (drop if Current advanced and signer left). func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { h := vote.Msg().Header() - duo := s.epochDuo.Load() - c := duo.Current.Committee() - if err := vote.Msg().Verify(c); err != nil { + // Future-epoch voters park (one stream goroutine) until Current includes them. + var committee *types.Committee + var verifiedEpoch types.EpochIndex + for inner, ctrl := range s.inner.Lock() { + if err := ctrl.WaitUntil(ctx, func() bool { + c := inner.epochDuo.Load().Current.Committee() + return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) + }); err != nil { + return err + } + duo := inner.epochDuo.Load() + committee = duo.Current.Committee() + verifiedEpoch = duo.Current.EpochIndex() + } + if err := vote.Msg().Verify(committee); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } - if err := vote.VerifySig(c); err != nil { + if err := vote.VerifySig(committee); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } - verifiedEpoch := duo.Current.EpochIndex() for inner, ctrl := range s.inner.Lock() { ls, ok := inner.lanes[h.Lane()] if !ok { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 9a0f382c1c..7aaa3b087a 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -463,9 +463,69 @@ func TestWaitPruneLeash(t *testing.T) { require.ErrorIs(t, state.waitPruneLeash(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) } -// TestPushVote_DropsSignerAfterEpochAdvance: PushVote verifies off-lock, then -// WaitUntil releases the lock; advanceEpoch installs a Current that excludes the -// signer — the vote must be dropped (Weight==0). +// TestPushVote_WaitsForFutureEpochSigner: a voter not yet in Current parks until +// advanceEpoch installs a committee that includes them, then credits. +func TestPushVote_WaitsForFutureEpochSigner(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + ep0 := state.epochDuo.Load().Current + futureKey := types.GenSecretKey(rng) + lane := futureKey.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(futureKey, types.NewLaneVote(header)) + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() // parked: Weight==0 under Current + + weights := map[types.PublicKey]uint64{ + futureKey.Public(): 1000, + keys[1].Public(): 1000, + } + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, + ep0.FirstTimestamp(), utils.OrPanic1(types.NewCommittee(weights)), ep0.FirstBlock()) + duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(duo1) + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + ls, ok := inner.lanes[lane] + require.True(t, ok) + require.Contains(t, ls.votes.q[0].byKey, futureKey.Public()) + } + }) +} + +// TestPushVote_FutureEpochSignerParks: canceled ctx while signer is not in +// Current returns Canceled (park), not a verify error. +func TestPushVote_FutureEpochSignerParks(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + futureKey := types.GenSecretKey(rng) + lane := futureKey.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(futureKey, types.NewLaneVote(header)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + require.ErrorIs(t, state.PushVote(ctx, vote), context.Canceled) +} + +// TestPushVote_DropsSignerAfterEpochAdvance: after verify, capacity WaitUntil +// releases the lock; advanceEpoch installs a Current that excludes the signer — +// the vote must be dropped (Weight==0). func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx := t.Context() From eb0e7007ae2542c956c3228d4ee23f2228022b8e Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 17:24:51 -0700 Subject: [PATCH 50/85] Address pompon0 avail API/structure nits. Rename waitForAppQC/NextCommitQC, return ErrPruned for stale roads, inline admit helpers, resolve restore verify against start duo, defer startup WAL prune to runPersist, and clarify advanceEpoch/lane comments. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 28 ++- .../internal/autobahn/avail/inner_test.go | 68 +++--- .../internal/autobahn/avail/state.go | 206 ++++++++---------- .../internal/autobahn/avail/state_test.go | 20 +- .../internal/autobahn/consensus/state.go | 2 +- .../internal/autobahn/consensus/state_test.go | 2 +- 6 files changed, 146 insertions(+), 180 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 4f78891bd9..ef7ffb9302 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -21,7 +21,6 @@ type inner struct { 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] @@ -30,7 +29,9 @@ type laneState struct { // TODO: consider giving this its own AtomicSend to avoid waking unrelated // inner waiters (PushVote, PushCommitQC, etc.) on markBlockPersisted calls. nextBlockToPersist types.BlockNumber - // persistedBlockStart is the admission watermark from the prune anchor. + // persistedBlockStart gates PushBlock/PushVote capacity (start+BlocksPerLane). + // Set from the prune-anchor LaneRange on load / after durable anchor write. + // TODO: revisit whether in-mem tipcut alone is enough (side note for another PR). persistedBlockStart types.BlockNumber } @@ -76,7 +77,11 @@ func (ls *loadedAvailState) nextCommitQC() types.RoadIndex { return tip } -func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded utils.Option[*loadedAvailState]) (*inner, error) { +func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils.Option[*loadedAvailState]) (*inner, error) { + startEpochDuo, err := registry.DuoAt(commitTip) + if err != nil { + return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) + } lanes := map[types.LaneID]*laneState{} // TODO(lane-id): also seed Prev lanes before prune so restart applies the // anchor watermark to them (today only Current is pre-created; Prev lanes @@ -111,7 +116,7 @@ func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded uti slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), slog.Uint64("globalNumber", uint64(anchor.AppQC.Proposal().GlobalNumber())), ) - if err := verifyLoadedCommitQC(registry, anchor.CommitQC); err != nil { + if err := verifyCommitQCInDuo(startEpochDuo, anchor.CommitQC); err != nil { return nil, fmt.Errorf("load prune-anchor CommitQC: %w", err) } if _, err := i.prune(anchor.AppQC, anchor.CommitQC); err != nil { @@ -141,7 +146,7 @@ func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded uti 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 { + if err := verifyCommitQCInDuo(startEpochDuo, lqc.QC); err != nil { return nil, fmt.Errorf("load CommitQC %d: %w", lqc.Index, err) } i.commitQCs.pushBack(lqc.QC) @@ -182,10 +187,9 @@ func newInner(registry *epoch.Registry, startEpochDuo types.EpochDuo, loaded uti 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()) +// verifyCommitQCInDuo verifies qc against startEpochDuo (Prev|Current at restore). +func verifyCommitQCInDuo(duo types.EpochDuo, qc *types.CommitQC) error { + ep, err := duo.EpochForRoad(qc.Proposal().Index()) if err != nil { return fmt.Errorf("epoch lookup: %w", err) } @@ -203,8 +207,10 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ return bv.laneQC() } -// advanceEpoch installs nextDuo at a boundary. Adds Current lanes; does not -// delete old lanes (TODO(lane-expiry)). +// advanceEpoch installs nextDuo at a boundary. Caller must ensure nextDuo is +// the next epoch after Current and that seal leashes (waitForAppQC, registry +// WaitForDuo) are already satisfied. Adds Current lanes; does not delete old +// lanes (TODO(lane-expiry)). func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { current := nextDuo.Current for lane := range current.Committee().Lanes().All() { diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 5626b335de..b640322884 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -72,7 +72,6 @@ func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber func TestNewInnerFreshStart(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistryAt(rng, 4, 0) - duo := utils.OrPanic1(registry.DuoAt(0)) for _, tc := range []struct { name string @@ -82,7 +81,7 @@ func TestNewInnerFreshStart(t *testing.T) { {"empty_loaded", utils.Some(&loadedAvailState{})}, } { t.Run(tc.name, func(t *testing.T) { - i, err := newInner(registry, duo, tc.loaded) + i, err := newInner(registry, 0, tc.loaded) require.NoError(t, err) require.False(t, i.latestAppQC.IsPresent()) @@ -118,15 +117,14 @@ func TestDecodePruneAnchorIncomplete(t *testing.T) { func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() registry, _, m := epoch.GenRegistryTip(rng, 4) - duo := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(m))) - require.Equal(t, m, duo.Current.EpochIndex()) + require.Equal(t, m, utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(m))).Current.EpochIndex()) // Prune-leashing seal of 0 means Current > 0 always has an AppQC anchor; // missing one on restart is a hard fail (do not weaken that contract). - _, err := newInner(registry, duo, utils.Some(&loadedAvailState{})) + _, err := newInner(registry, epoch.FirstRoad(m), utils.Some(&loadedAvailState{})) require.Error(t, err) require.Contains(t, err.Error(), "prune anchor required") - _, err = newInner(registry, duo, utils.None[*loadedAvailState]()) + _, err = newInner(registry, epoch.FirstRoad(m), utils.None[*loadedAvailState]()) require.Error(t, err) require.Contains(t, err.Error(), "prune anchor required") } @@ -149,7 +147,7 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) q := i.lanes[lane].blocks @@ -178,7 +176,7 @@ func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: {}}, } - i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) q := i.lanes[lane].blocks @@ -198,7 +196,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{unknownLane: {{Number: 0, Proposal: b}}}, } - i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) for lane := range registry.LatestEpoch().Committee().Lanes().All() { @@ -235,7 +233,7 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs0, lane1: bs1}, } - i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) q0 := i.lanes[lane0].blocks @@ -272,7 +270,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // Without anchor, commitQCs.first = 0. All 3 should be restored. @@ -318,7 +316,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // latestAppQC should be set by prune. @@ -379,7 +377,7 @@ func TestNewInnerLoadedAllThree(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // AppQC restored. @@ -408,7 +406,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() - i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.None[*loadedAvailState]()) + i, err := newInner(registry, 0, utils.None[*loadedAvailState]()) require.NoError(t, err) // Push blocks 0-4 on one lane. @@ -483,7 +481,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[5]}), } - inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // tipcut insert after prune places the anchor's CommitQC. @@ -512,7 +510,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), } - inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // tipcut insert after prune places the anchor's CommitQC. @@ -555,7 +553,7 @@ func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } @@ -568,7 +566,7 @@ func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { commitQCs: nil, } - inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) @@ -603,7 +601,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // Only QC@10 loaded. @@ -652,7 +650,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // tipcut insert places QC@3 (next=4). Indices 1,2,3 are skipped. 4,5 pushed. @@ -691,7 +689,7 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } @@ -715,7 +713,7 @@ func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } @@ -743,7 +741,7 @@ func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "parent hash mismatch") } @@ -769,7 +767,7 @@ func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "exceeds capacity") } @@ -812,7 +810,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // prune() should advance block queue first to the prune anchor's lane range. @@ -848,7 +846,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { }, } - i, err := newInner(registry, utils.OrPanic1(registry.DuoAt(0)), utils.Some(loaded)) + i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // prune(appQC@1, pruneQC@1) should advance commitQCs.first to 1. @@ -872,13 +870,6 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { prev = utils.Some(qcs[i]) } wantAppFirst := qcs[1].GlobalRange().First - // 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.FirstRoad(1), Next: epoch.FirstRoad(2)}, ep0.FirstTimestamp(), c, tipFirst) - tipDuo := types.NewEpochDuo(tipCurrent, utils.Some(ep0)) - ap := types.NewAppProposal(wantAppFirst, qcs[1].Index(), types.GenAppHash(rng), 0) loaded := &loadedAvailState{ pruneAnchor: utils.Some(&PruneAnchor{ @@ -891,11 +882,11 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { }, } - inner, err := newInner(registry, tipDuo, utils.Some(loaded)) + inner, err := newInner(registry, loaded.nextCommitQC(), 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) + require.NotEqual(t, inner.epochDuo.Load().Current.FirstBlock(), inner.appVotes.first) } func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { @@ -903,7 +894,7 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { registry, _ := epoch.GenRegistryAt(rng, 4, 0) duo := utils.OrPanic1(registry.DuoAt(0)) - i, err := newInner(registry, duo, utils.None[*loadedAvailState]()) + i, err := newInner(registry, 0, utils.None[*loadedAvailState]()) require.NoError(t, err) // All current lanes are present after construction. @@ -933,13 +924,12 @@ func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { registry, _ := epoch.GenRegistryAt(rng, 4, 0) registry.EnsureEpoch(1) - duo0 := utils.OrPanic1(registry.DuoAt(0)) - i, err := newInner(registry, duo0, utils.None[*loadedAvailState]()) + i, err := newInner(registry, 0, utils.None[*loadedAvailState]()) require.NoError(t, err) // Collect a lane from Current (will become Prev after advance). var prevLane types.LaneID - for l := range duo0.Current.Committee().Lanes().All() { + for l := range i.epochDuo.Load().Current.Committee().Lanes().All() { prevLane = l break } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index a4297fed73..39f4f1e296 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -38,6 +38,10 @@ type State struct { // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. persisters persisters + + // startupWALPrune, if set, is the prune-anchor CommitQC used once at the + // start of runPersist to truncate WAL entries filtered out of memory at load. + startupWALPrune utils.Option[*types.CommitQC] } func (s *State) PublicKey() types.PublicKey { @@ -156,45 +160,31 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } - // DuoAt(CommitQC tipcut). Seeding is data.SetupInitialDuo; missing epoch hard-fails. + // DuoAt(CommitQC tipcut) happens inside newInner. Seeding is + // data.SetupInitialDuo; missing epoch hard-fails. // Tip order: consensus.NewState requires avail ≥ consensus; avail/consensus // may lag data and catch up in Run. commitTip := types.RoadIndex(0) + startupWALPrune := utils.None[*types.CommitQC]() if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() + if anchor, ok := ls.pruneAnchor.Get(); ok { + // Disk truncate of filtered-out WAL entries runs once in runPersist. + startupWALPrune = utils.Some(anchor.CommitQC) + } } - startDuo, err := data.Registry().DuoAt(commitTip) - if err != nil { - return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) - } - inner, err := newInner(data.Registry(), startDuo, loaded) + inner, err := newInner(data.Registry(), commitTip, 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) duo. - // TODO(lane-id): also prune Prev committee lanes on restart (same as - // newInner Prev-lane seeding). Next Lane ID PR. - if ls, ok := loaded.Get(); ok { - if anchor, ok := ls.pruneAnchor.Get(); ok { - 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) - } - } - if err := pers.commitQCs.MaybePruneAndPersist(utils.Some(anchor.CommitQC), nil, utils.None[func(*types.CommitQC)]()); err != nil { - return nil, fmt.Errorf("prune stale commitQC WAL entries: %w", err) - } - } - } - return &State{ - key: key, - data: data, - inner: utils.NewWatch(inner), - epochDuo: inner.epochDuo.Subscribe(), - persisters: pers, + key: key, + data: data, + inner: utils.NewWatch(inner), + epochDuo: inner.epochDuo.Subscribe(), + persisters: pers, + startupWALPrune: startupWALPrune, }, nil } @@ -205,8 +195,8 @@ 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 { +// NextCommitQC is the next CommitQC road after restore/admit (commitQCs.next). +func (s *State) NextCommitQC() types.RoadIndex { for inner := range s.inner.Lock() { return inner.commitQCs.next } @@ -233,85 +223,52 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error return err } -// waitRoadInWindow blocks while roadIdx is ahead of the admitted window -// (too early / backpressure). Returns Some(duo) 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.EpochDuo], error) { +func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { + // Debug: Info is too chatty at epoch boundaries (many peers a road behind). + logger.Debug("dropping stale "+what+": road behind window", + slog.Uint64("road", uint64(roadIdx)), "duo", duo.String()) +} + +// waitForEpoch waits until roadIdx is in Prev|Current. ErrPruned if behind window. +func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { - if lookup(duo).IsPresent() { + if _, err := duo.EpochForRoad(roadIdx); err == nil { return true } - return roadIdx < windowFirst(duo) + first := duo.Current.RoadRange().First + if prev, ok := duo.Prev.Get(); ok { + first = prev.RoadRange().First + } + return roadIdx < first }) if err != nil { - return utils.None[types.EpochDuo](), err + return types.EpochDuo{}, err } - if lookup(duo).IsPresent() { - return utils.Some(duo), nil + if _, err := duo.EpochForRoad(roadIdx); err == nil { + return duo, nil } - return utils.None[types.EpochDuo](), nil -} - -// waitForEpoch: too early waits until road is in Prev|Current; behind window → None (stale). -func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[types.EpochDuo], error) { - return s.waitRoadInWindow(ctx, roadIdx, - func(duo types.EpochDuo) utils.Option[*types.Epoch] { - if ep, err := duo.EpochForRoad(roadIdx); err == nil { - return utils.Some(ep) - } - return utils.None[*types.Epoch]() - }, - func(duo types.EpochDuo) types.RoadIndex { - if prev, ok := duo.Prev.Get(); ok { - return prev.RoadRange().First - } - return duo.Current.RoadRange().First - }, - ) -} - -// waitCurrentForRoad: too early waits on Current; behind Current → None (stale). -func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) (utils.Option[types.EpochDuo], error) { - return s.waitRoadInWindow(ctx, roadIdx, - func(duo types.EpochDuo) utils.Option[*types.Epoch] { - if duo.Current.RoadRange().Has(roadIdx) { - return utils.Some(duo.Current) - } - return utils.None[*types.Epoch]() - }, - func(duo types.EpochDuo) types.RoadIndex { return duo.Current.RoadRange().First }, - ) + return types.EpochDuo{}, types.ErrPruned } -// admitRoadOrDrop waits for window admission. On stale it logs and returns -// None 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.EpochDuo], error), -) (utils.Option[types.EpochDuo], error) { - duoOpt, err := wait(ctx, roadIdx) +// waitCurrentForRoad waits until roadIdx is in Current. ErrPruned if behind Current. +func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, 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.EpochDuo](), err + return types.EpochDuo{}, err } - if !duoOpt.IsPresent() { - // Debug: Info is too chatty at epoch boundaries (many peers a road behind). - logger.Debug("dropping stale "+what+": road behind window", - slog.Uint64("road", uint64(roadIdx)), "duo", s.epochDuo.Load().String()) - return utils.None[types.EpochDuo](), nil + if duo.Current.RoadRange().Has(roadIdx) { + return duo, nil } - return duoOpt, nil + return types.EpochDuo{}, types.ErrPruned } -// waitPruneLeash blocks until latest AppQC is from epochIdx or later. -// incoming, if present, counts before latestAppQC is updated. +// waitForAppQC blocks until latest AppQC is from epochIdx or later. +// incoming, if present, is checked off-mutex before waiting on latestAppQC. // // Called only when sealing epoch N (last road). Epoch 0 is not special-cased: // seal is {∅,0}→{0,1} (no Prev drop), but the leash still runs so leaving 0 @@ -319,12 +276,12 @@ func (s *State) admitRoadOrDrop( // reintroduce an epochIdx==0 skip: BlocksPerLane only caps local production; // peers can PushCommitQC LastRoad(0) (then mid-epoch-1 QCs) with no local // AppQC, which would otherwise restart without an anchor. -func (s *State) waitPruneLeash(ctx context.Context, epochIdx types.EpochIndex, incoming utils.Option[*types.AppQC]) error { +func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex, incoming utils.Option[*types.AppQC]) error { + if c, ok := incoming.Get(); ok && c.Proposal().EpochIndex() >= epochIdx { + return nil + } 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 @@ -414,14 +371,14 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - duoOpt, err := s.admitRoadOrDrop(ctx, idx, "CommitQC", s.waitCurrentForRoad) + duo, err := s.waitCurrentForRoad(ctx, idx) if err != nil { + if errors.Is(err, types.ErrPruned) { + logStaleRoad("CommitQC", idx, s.epochDuo.Load()) + return nil + } return err } - duo, ok := duoOpt.Get() - if !ok { - return nil - } ep := duo.Current if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) @@ -430,7 +387,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { // Boundary: switch to the next epoch on Current's last CommitQC. nextDuo := utils.None[types.EpochDuo]() if ep.RoadRange().IsLastRoad(idx) { - if err := s.waitPruneLeash(ctx, ep.EpochIndex(), utils.None[*types.AppQC]()); err != nil { + if err := s.waitForAppQC(ctx, ep.EpochIndex(), utils.None[*types.AppQC]()); err != nil { return err } nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) @@ -466,14 +423,14 @@ 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. - duoOpt, err := s.admitRoadOrDrop(ctx, idx, "AppVote", s.waitForEpoch) + duo, err := s.waitForEpoch(ctx, idx) if err != nil { + if errors.Is(err, types.ErrPruned) { + logStaleRoad("AppVote", idx, s.epochDuo.Load()) + return nil + } return err } - duo, ok := duoOpt.Get() - if !ok { - return nil - } ep := utils.OrPanic1(duo.EpochForRoad(idx)) if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want { return fmt.Errorf("appVote epoch_index %d, want %d", got, want) @@ -524,7 +481,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } } - // Pair consistency only; ahead-of-window still waits in admitRoadOrDrop. + // Pair consistency only; ahead-of-window still waits in waitForEpoch. if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) } @@ -535,14 +492,14 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return fmt.Errorf("appQC GlobalNumber not in commitQC range") } idx := commitQC.Proposal().Index() - duoOpt, err := s.admitRoadOrDrop(ctx, idx, "AppQC", s.waitForEpoch) + duo, err := s.waitForEpoch(ctx, idx) if err != nil { + if errors.Is(err, types.ErrPruned) { + logStaleRoad("AppQC", idx, s.epochDuo.Load()) + return nil + } return err } - duo, ok := duoOpt.Get() - if !ok { - return nil - } ep := utils.OrPanic1(duo.EpochForRoad(idx)) if err := appQC.Verify(ep); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) @@ -554,7 +511,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ // Pass this AppQC as incoming so a tipcut that first enters epoch N can close N. nextDuo := utils.None[types.EpochDuo]() if ep.RoadRange().IsLastRoad(idx) { - if err := s.waitPruneLeash(ctx, ep.EpochIndex(), utils.Some(appQC)); err != nil { + if err := s.waitForAppQC(ctx, ep.EpochIndex(), utils.Some(appQC)); err != nil { return err } nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) @@ -937,6 +894,21 @@ func (s *State) Run(ctx context.Context) error { // TODO: use a single WAL for anchor and CommitQCs to make // this atomic rather than relying on write order. func (s *State) runPersist(ctx context.Context, pers persisters) error { + // Truncate WAL entries filtered out of memory at load (once). + // TODO(lane-id): also prune Prev committee lanes on restart (same as + // newInner Prev-lane seeding). Next Lane ID PR. + if anchorQC, ok := s.startupWALPrune.Get(); ok { + s.startupWALPrune = utils.None[*types.CommitQC]() + for lane := range s.epochDuo.Load().Current.Committee().Lanes().All() { + if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchorQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { + return fmt.Errorf("prune stale block WAL entries: %w", err) + } + } + if err := pers.commitQCs.MaybePruneAndPersist(utils.Some(anchorQC), nil, utils.None[func(*types.CommitQC)]()); err != nil { + return fmt.Errorf("prune stale commitQC WAL entries: %w", err) + } + } + var lastPersistedAppQCNext types.RoadIndex for { batch, err := s.collectPersistBatch(ctx, lastPersistedAppQCNext) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 7aaa3b087a..6207c296a6 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -423,7 +423,7 @@ func TestStateMismatchedQCs(t *testing.T) { }) } -func TestWaitPruneLeash(t *testing.T) { +func TestWaitForAppQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) @@ -436,7 +436,7 @@ func TestWaitPruneLeash(t *testing.T) { timeout, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() - require.ErrorIs(t, state.waitPruneLeash(timeout, 0, utils.None[*types.AppQC]()), context.DeadlineExceeded) + require.ErrorIs(t, state.waitForAppQC(timeout, 0, utils.None[*types.AppQC]()), context.DeadlineExceeded) lane := keys[0].Public() b, err := state.ProduceLocalBlock(state.NextBlock(lane), types.GenPayload(rng)) @@ -453,14 +453,14 @@ func TestWaitPruneLeash(t *testing.T) { qc0.GlobalRange().Next-1, 0, types.GenAppHash(rng), 0))) done := make(chan error, 1) - go func() { done <- state.waitPruneLeash(ctx, 0, utils.None[*types.AppQC]()) }() + go func() { done <- state.waitForAppQC(ctx, 0, utils.None[*types.AppQC]()) }() require.NoError(t, state.PushAppQC(ctx, appQC, qc0)) require.NoError(t, <-done) - require.NoError(t, state.waitPruneLeash(ctx, 0, utils.None[*types.AppQC]())) + require.NoError(t, state.waitForAppQC(ctx, 0, utils.None[*types.AppQC]())) timeout2, cancel2 := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel2() - require.ErrorIs(t, state.waitPruneLeash(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) + require.ErrorIs(t, state.waitForAppQC(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) } // TestPushVote_WaitsForFutureEpochSigner: a voter not yet in Current parks until @@ -1120,13 +1120,11 @@ func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { registerDuoAtEpoch(state, m) // Prev=M-1|Current=M roadInPrev := epoch.FirstRoad(m - 1) - duoWin, err := state.waitForEpoch(t.Context(), roadInPrev) - require.NoError(t, err) - require.True(t, duoWin.IsPresent(), "Prev|Current window still covers Prev roads") + _, err = state.waitForEpoch(t.Context(), roadInPrev) + require.NoError(t, err, "Prev|Current window still covers Prev roads") - duoCur, err := state.waitCurrentForRoad(t.Context(), roadInPrev) - require.NoError(t, err) - require.False(t, duoCur.IsPresent(), "Current-only wait must treat Prev roads as too late") + _, err = state.waitCurrentForRoad(t.Context(), roadInPrev) + require.ErrorIs(t, err, types.ErrPruned, "Current-only wait must treat Prev roads as too late") } func TestPushCommitQCStaleDrops(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index f4b86e9a65..c0ed5503b8 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -140,7 +140,7 @@ func newState( // lag data; Run() catch-up from avail closes that gap. // // avail < cons is not auto-repaired (see ErrAvailBehindConsensus). - if availTip, consTip := availState.CommitTipCut(), s.CommitTipCut(); availTip < consTip { + if availTip, consTip := availState.NextCommitQC(), s.CommitTipCut(); availTip < consTip { return nil, fmt.Errorf("%w: avail tipcut %d < consensus tipcut %d", ErrAvailBehindConsensus, availTip, consTip) } return s, nil diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index c004b57f95..8ab21277df 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -435,7 +435,7 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { require.NoError(t, err) consTip := cs.CommitTipCut() - availTip := cs.Avail().CommitTipCut() + availTip := cs.Avail().NextCommitQC() require.Equal(t, leadTip, consTip) require.Equal(t, leadTip, availTip) require.Equal(t, nPlus1, epoch.IndexForRoad(consTip)) From a1ecb403ed5a23ba9cc1b61aa2b3be13c107c256 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 17:40:49 -0700 Subject: [PATCH 51/85] Avoid uint64 wrap in AppQC Current-1 epoch checks. Use cur > 0 && appEp == cur-1 instead of appEp+1 so MaxUint64 cannot pass the window gate when Current is 0. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index d1df84b305..8bf5e29710 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -366,9 +366,10 @@ func buildProposal( } // AppQC must be Current or Current-1. Outside that window, drop the // candidate and keep the prior CommitQC App (no new appQC). + // Use cur-1 (not appEp+1) so uint64 wrap cannot admit MaxUint64 when cur==0. if a, ok := app.Get(); ok { appEp, cur := a.EpochIndex(), viewSpec.Epoch().EpochIndex() - if appEp != cur && appEp+1 != cur { + if appEp != cur && !(cur > 0 && appEp == cur-1) { app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } @@ -519,7 +520,8 @@ func (m *FullProposal) Verify(vs ViewSpec) error { appEpoch := app.EpochIndex() cur := vs.Epoch().EpochIndex() // Allow Current or Current-1 (Prev lag). Reject anything else. - if appEpoch != cur && appEpoch+1 != cur { + // Use cur-1 (not appEpoch+1) so uint64 wrap cannot admit MaxUint64 when cur==0. + if appEpoch != cur && !(cur > 0 && appEpoch == cur-1) { return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEpoch, cur) } appQC, ok := m.appQC.Get() From e55cc61227e7f7497289e52dba1f0a9bac76fd64 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 27 Jul 2026 17:52:34 -0700 Subject: [PATCH 52/85] Fix seidroid blockers and AppQC lint. Use De Morgan-safe Current-1 checks, reject missing Prev instead of panic, and restore PushVote verify-before-wait so unknown signers cannot wedge a peer stream. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 6 +- .../internal/autobahn/avail/state.go | 35 ++++------- .../internal/autobahn/avail/state_test.go | 60 ------------------- 3 files changed, 16 insertions(+), 85 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 8bf5e29710..8a5eb7d5db 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -369,7 +369,7 @@ func buildProposal( // Use cur-1 (not appEp+1) so uint64 wrap cannot admit MaxUint64 when cur==0. if a, ok := app.Get(); ok { appEp, cur := a.EpochIndex(), viewSpec.Epoch().EpochIndex() - if appEp != cur && !(cur > 0 && appEp == cur-1) { + if appEp != cur && (cur == 0 || appEp != cur-1) { app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } @@ -521,7 +521,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { cur := vs.Epoch().EpochIndex() // Allow Current or Current-1 (Prev lag). Reject anything else. // Use cur-1 (not appEpoch+1) so uint64 wrap cannot admit MaxUint64 when cur==0. - if appEpoch != cur && !(cur > 0 && appEpoch == cur-1) { + if appEpoch != cur && (cur == 0 || appEpoch != cur-1) { return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEpoch, cur) } appQC, ok := m.appQC.Get() @@ -536,7 +536,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { if appEpoch != cur { prev, ok := vs.Epochs.Prev.Get() if !ok { - panic(fmt.Sprintf("appQC epoch %d needs Prev, but Prev is absent (Current %d)", appEpoch, cur)) + return fmt.Errorf("appQC epoch %d needs Prev, but Prev is absent (Current %d)", appEpoch, cur) } ep = prev } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 39f4f1e296..667a8b8021 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -581,9 +581,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()) } - // Snapshot Current once for off-lock verify. Unlike PushVote (which parks - // until Current accepts the signer), we do not wait for future committees — - // lane proposals are not reweighted across epoch advances. + // Snapshot Current once for off-lock verify. Unlike PushVote, we do not + // re-check membership after WaitUntil — lane proposals are not reweighted + // across epoch advances (genesis committees are stable today). duo := s.epochDuo.Load() c := duo.Current.Committee() if err := p.Msg().Verify(c); err != nil { @@ -633,31 +633,22 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos return nil } -// PushVote parks until Current can accept the vote (signer weight + voted lane), -// verifies, then under lock waits for capacity and credits with the live duo -// (drop if Current advanced and signer left). +// PushVote verifies against Current, then under lock waits for lane capacity and +// credits with the live duo (drop if Current advanced and signer left). +// Signers not in Current fail immediately (no park) so a peer stream cannot be +// wedged on unauthenticated input; future-committee admission is a follow-up +// once per-epoch committees are real. func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { h := vote.Msg().Header() - // Future-epoch voters park (one stream goroutine) until Current includes them. - var committee *types.Committee - var verifiedEpoch types.EpochIndex - for inner, ctrl := range s.inner.Lock() { - if err := ctrl.WaitUntil(ctx, func() bool { - c := inner.epochDuo.Load().Current.Committee() - return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) - }); err != nil { - return err - } - duo := inner.epochDuo.Load() - committee = duo.Current.Committee() - verifiedEpoch = duo.Current.EpochIndex() - } - if err := vote.Msg().Verify(committee); err != nil { + duo := s.epochDuo.Load() + c := duo.Current.Committee() + if err := vote.Msg().Verify(c); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } - if err := vote.VerifySig(committee); err != nil { + if err := vote.VerifySig(c); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } + verifiedEpoch := duo.Current.EpochIndex() for inner, ctrl := range s.inner.Lock() { ls, ok := inner.lanes[h.Lane()] if !ok { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 6207c296a6..db9907fd7a 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -463,66 +463,6 @@ func TestWaitForAppQC(t *testing.T) { require.ErrorIs(t, state.waitForAppQC(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) } -// TestPushVote_WaitsForFutureEpochSigner: a voter not yet in Current parks until -// advanceEpoch installs a committee that includes them, then credits. -func TestPushVote_WaitsForFutureEpochSigner(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx := t.Context() - rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) - ds := newTestDataState(&data.Config{Registry: registry}) - state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) - - ep0 := state.epochDuo.Load().Current - futureKey := types.GenSecretKey(rng) - lane := futureKey.Public() - header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() - vote := types.Sign(futureKey, types.NewLaneVote(header)) - - errCh := make(chan error, 1) - go func() { errCh <- state.PushVote(ctx, vote) }() - synctest.Wait() // parked: Weight==0 under Current - - weights := map[types.PublicKey]uint64{ - futureKey.Public(): 1000, - keys[1].Public(): 1000, - } - ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, - ep0.FirstTimestamp(), utils.OrPanic1(types.NewCommittee(weights)), ep0.FirstBlock()) - duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) - for inner, ctrl := range state.inner.Lock() { - inner.advanceEpoch(duo1) - ctrl.Updated() - } - synctest.Wait() - require.NoError(t, <-errCh) - - for inner := range state.inner.Lock() { - ls, ok := inner.lanes[lane] - require.True(t, ok) - require.Contains(t, ls.votes.q[0].byKey, futureKey.Public()) - } - }) -} - -// TestPushVote_FutureEpochSignerParks: canceled ctx while signer is not in -// Current returns Canceled (park), not a verify error. -func TestPushVote_FutureEpochSignerParks(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) - ds := newTestDataState(&data.Config{Registry: registry}) - state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) - - futureKey := types.GenSecretKey(rng) - lane := futureKey.Public() - header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() - vote := types.Sign(futureKey, types.NewLaneVote(header)) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - - require.ErrorIs(t, state.PushVote(ctx, vote), context.Canceled) -} - // TestPushVote_DropsSignerAfterEpochAdvance: after verify, capacity WaitUntil // releases the lock; advanceEpoch installs a Current that excludes the signer — // the vote must be dropped (Weight==0). From 112ef30fbe659947b05907429c14098cca60e20a Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 10:29:37 -0700 Subject: [PATCH 53/85] Remove FirstBlock/FirstTimestamp from Epoch. Genesis floors live on Registry and ViewSpec (CommitQC-absent path only); placeholders must not pretend per-epoch floors are known without CommitQCs. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/app_qc_test.go | 3 +- .../autobahn/types/committee_test.go | 8 +- sei-tendermint/autobahn/types/epoch.go | 32 +++--- .../autobahn/types/epoch_duo_test.go | 12 +- sei-tendermint/autobahn/types/proposal.go | 10 +- .../autobahn/types/proposal_test.go | 107 ++++++++++-------- sei-tendermint/autobahn/types/testonly.go | 22 ++-- sei-tendermint/autobahn/types/types_test.go | 6 +- .../autobahn/types/wireguard_test.go | 2 +- .../autobahn/avail/block_votes_test.go | 3 +- .../internal/autobahn/avail/inner.go | 8 +- .../internal/autobahn/avail/inner_test.go | 8 +- .../internal/autobahn/avail/state_test.go | 10 +- .../internal/autobahn/consensus/inner.go | 2 + .../consensus/persist/commitqcs_test.go | 3 +- .../autobahn/consensus/persisted_inner.go | 7 +- .../internal/autobahn/consensus/state.go | 25 +++- .../internal/autobahn/data/state.go | 2 +- .../internal/autobahn/epoch/registry.go | 31 +++-- .../internal/autobahn/epoch/registry_test.go | 6 +- 20 files changed, 176 insertions(+), 131 deletions(-) diff --git a/sei-tendermint/autobahn/types/app_qc_test.go b/sei-tendermint/autobahn/types/app_qc_test.go index f0d847ffd6..83877c4939 100644 --- a/sei-tendermint/autobahn/types/app_qc_test.go +++ b/sei-tendermint/autobahn/types/app_qc_test.go @@ -2,7 +2,6 @@ package types import ( "testing" - "time" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" @@ -11,7 +10,7 @@ import ( func TestAppQCVerifyChecksEpochAndRoad(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(1, RoadRange{First: 100, Next: 200}, time.Time{}, committee, 1) + ep := NewEpoch(1, RoadRange{First: 100, Next: 200}, committee) ok := makeAppQCFor(keys, 0, 150, GenAppHash(rng), 1) require.NoError(t, ok.Verify(ep)) diff --git a/sei-tendermint/autobahn/types/committee_test.go b/sei-tendermint/autobahn/types/committee_test.go index 58db7ce172..ba14b06178 100644 --- a/sei-tendermint/autobahn/types/committee_test.go +++ b/sei-tendermint/autobahn/types/committee_test.go @@ -129,10 +129,10 @@ func TestPrepareQCVerifyChecksEpochBinding(t *testing.T) { require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First})).Verify(ep)) - wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) + wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), 1) require.Error(t, sign(wrongEpoch).Verify(ep)) - outOfRoads := newProposal(View{Index: ep.RoadRange().Next, 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](), 1) require.Error(t, sign(outOfRoads).Verify(ep)) } @@ -145,10 +145,10 @@ func TestCommitQCVerifyChecksEpochBinding(t *testing.T) { require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First})).Verify(ep)) - wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) + wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), 1) require.Error(t, sign(wrongEpoch).Verify(ep)) - outOfRoads := newProposal(View{Index: ep.RoadRange().Next, 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](), 1) require.Error(t, sign(outOfRoads).Verify(ep)) } diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index de488eb090..2b4263571b 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -1,8 +1,6 @@ package types import ( - "time" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) @@ -26,27 +24,25 @@ func (r RoadRange) IsLastRoad(idx RoadIndex) bool { return idx+1 == r.Next } // Epoch holds the complete context for a single epoch. // Retrieved from the local Registry; never transmitted on the wire. +// +// First global block / timestamp floors are not on Epoch: they come from the +// last CommitQC of the prior epoch (or genesis GenDoc via Registry), and the +// registry does not store CommitQCs. type Epoch struct { utils.ReadOnly - epochIndex EpochIndex - roads RoadRange - firstTimestamp time.Time - committee *Committee - firstBlock GlobalBlockNumber + epochIndex EpochIndex + roads RoadRange + committee *Committee } -func NewEpoch(index EpochIndex, roads RoadRange, firstTimestamp time.Time, committee *Committee, firstBlock GlobalBlockNumber) *Epoch { +func NewEpoch(index EpochIndex, roads RoadRange, committee *Committee) *Epoch { return &Epoch{ - epochIndex: index, - roads: roads, - firstTimestamp: firstTimestamp, - committee: committee, - firstBlock: firstBlock, + epochIndex: index, + roads: roads, + committee: committee, } } -func (e *Epoch) EpochIndex() EpochIndex { return e.epochIndex } -func (e *Epoch) RoadRange() RoadRange { return e.roads } -func (e *Epoch) FirstTimestamp() time.Time { return e.firstTimestamp } -func (e *Epoch) Committee() *Committee { return e.committee } -func (e *Epoch) FirstBlock() GlobalBlockNumber { return e.firstBlock } +func (e *Epoch) EpochIndex() EpochIndex { return e.epochIndex } +func (e *Epoch) RoadRange() RoadRange { return e.roads } +func (e *Epoch) Committee() *Committee { return e.committee } diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 19f23428a7..8daacfd11b 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -16,8 +16,8 @@ func testDuoEpochs(t *testing.T) (prev, current *types.Epoch) { 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) + prev = types.NewEpoch(0, types.RoadRange{First: 0, Next: 100}, committee) + current = types.NewEpoch(1, types.RoadRange{First: 100, Next: 200}, committee) return prev, current } @@ -27,7 +27,7 @@ func TestNewEpochDuo_PanicsOnNonContiguousIndex(t *testing.T) { weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} committee := utils.OrPanic1(types.NewCommittee(weights)) // Roads abut, but index jumps 0 → 2. - current := types.NewEpoch(2, types.RoadRange{First: 100, Next: 200}, utils.GenTimestamp(rng), committee, 101) + current := types.NewEpoch(2, types.RoadRange{First: 100, Next: 200}, committee) defer func() { if recover() == nil { t.Fatal("NewEpochDuo with non-contiguous indices should panic") @@ -40,8 +40,8 @@ func TestNewEpochDuo_PanicsOnNonContiguousRoads(t *testing.T) { rng := utils.TestRng() weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} committee := utils.OrPanic1(types.NewCommittee(weights)) - prev := 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) + prev := types.NewEpoch(0, types.OpenRoadRange(), committee) + current := types.NewEpoch(1, types.RoadRange{First: 100, Next: 200}, committee) defer func() { if recover() == nil { t.Fatal("NewEpochDuo with non-abutting roads should panic") @@ -76,7 +76,7 @@ func TestEpochForRoad(t *testing.T) { weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} committee := utils.OrPanic1(types.NewCommittee(weights)) // Prev absent only for epoch 0. - ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 100}, utils.GenTimestamp(rng), committee, 1) + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 100}, committee) currentOnly := types.NewEpochDuo(ep0, utils.None[*types.Epoch]()) withPrev := types.NewEpochDuo(current, utils.Some(prev)) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 8a5eb7d5db..100d793dc6 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -134,19 +134,23 @@ type ViewSpec struct { CommitQC utils.Option[*CommitQC] TimeoutQC utils.Option[*TimeoutQC] Epochs EpochDuo + // Genesis floors used only when CommitQC is None (chain start). + // Copied from Registry; ignored when CommitQC is present. + GenesisFirstBlock GlobalBlockNumber + GenesisTimestamp time.Time } // 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. +// CommitQC is None only at chain start, in which case it returns GenesisFirstBlock. // For all other views, including the first view of a non-genesis epoch, CommitQC is present and it returns CommitQC.GlobalRange().Next. func (vs *ViewSpec) NextGlobalBlock() GlobalBlockNumber { if cQC, ok := vs.CommitQC.Get(); ok { return cQC.GlobalRange().Next } - return vs.Epoch().FirstBlock() + return vs.GenesisFirstBlock } // View is the view justified by vs. @@ -163,7 +167,7 @@ func (vs *ViewSpec) NextTimestamp() time.Time { if cQC, ok := vs.CommitQC.Get(); ok { return cQC.Proposal().NextTimestamp() } - return vs.Epoch().FirstTimestamp() + return vs.GenesisTimestamp } // Proposal is the road tipcut proposal. diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 078fed9cce..2bc9ef3e45 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -9,15 +9,26 @@ import ( ) // genFreshEpoch returns epoch 0 with OpenRoadRange (road 0 valid for a ViewSpec -// with no CommitQC). firstBlock is randomised so tests do not pass on zeros. -func genFreshEpoch(rng utils.Rng, committee *Committee) *Epoch { - return NewEpoch( - 0, - OpenRoadRange(), - time.Time{}, - committee, - GlobalBlockNumber(rng.Uint64()%1000000)+1, - ) +// with no CommitQC). +func genFreshEpoch(_ utils.Rng, committee *Committee) *Epoch { + return NewEpoch(0, OpenRoadRange(), committee) +} + +// genesisViewSpec builds a CommitQC-absent ViewSpec with randomised genesis +// floors so NextGlobalBlock/NextTimestamp tests do not pass on zeros. +func genesisViewSpec(rng utils.Rng, ep *Epoch) ViewSpec { + return ViewSpec{ + Epochs: EpochDuoForTest(ep), + GenesisFirstBlock: GlobalBlockNumber(rng.Uint64()%1000000) + 1, + GenesisTimestamp: utils.GenTimestamp(rng), + } +} + +// withTimeoutQC returns base with TimeoutQC set, preserving genesis floors +// (needed when CommitQC is still None, e.g. reproposal at view number > 0). +func withTimeoutQC(base ViewSpec, tqc *TimeoutQC) ViewSpec { + base.TimeoutQC = utils.Some(tqc) + return base } // viewSpecContiguousDuo builds Prev|Current with abutting roads [0,1)|[1,Max) and @@ -25,8 +36,8 @@ func genFreshEpoch(rng utils.Rng, committee *Committee) *Epoch { // Current-epoch AppQC at road 1 (strictly before the tipcut view). func viewSpecContiguousDuo(keys []SecretKey, committee *Committee) ViewSpec { const split RoadIndex = 1 - prev := NewEpoch(0, RoadRange{First: 0, Next: split}, time.Time{}, committee, 1) - current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 1) + prev := NewEpoch(0, RoadRange{First: 0, Next: split}, committee) + current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, committee) qc0 := BuildCommitQC(prev, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) qc1 := BuildCommitQC(current, keys, utils.Some(qc0), nil, utils.None[*AppQC]()) return ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(current, utils.Some(prev))} @@ -94,7 +105,7 @@ func TestProposalVerifyRejectsEmptyTipcut(t *testing.T) { rng := utils.TestRng() committee, _ := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) // Direct Proposal.Verify rejects empty tipcuts (no LaneQCs / zero GlobalRange). // Local propose waits via WaitForLaneQCs; verification must refuse empty tipcuts @@ -116,7 +127,7 @@ func TestProposalVerifyFreshWithBlocks(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) // Produce a LaneQC for the proposer's lane. @@ -132,7 +143,7 @@ func TestNewProposalRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testing. rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -151,8 +162,8 @@ func TestProposalBlockTimestampStrictlyMonotone(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - firstBlock := ep.FirstBlock() - vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs0 := genesisViewSpec(rng, ep) + firstBlock := vs0.GenesisFirstBlock proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() @@ -199,14 +210,18 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) genesisTimestamp := time.Now() - ep := NewEpoch(0, OpenRoadRange(), genesisTimestamp, committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + ep := NewEpoch(0, OpenRoadRange(), committee) + vs := ViewSpec{ + Epochs: EpochDuoForTest(ep), + GenesisFirstBlock: GlobalBlockNumber(rng.Uint64()%1000000) + 1, + GenesisTimestamp: genesisTimestamp, + } k := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) require.NoError(t, fp.Verify(vs)) vsLater := vs - vsLater.Epochs.Current = NewEpoch(ep.EpochIndex(), ep.RoadRange(), fp.Proposal().Msg().Timestamp().Add(time.Nanosecond), committee, ep.FirstBlock()) + vsLater.GenesisTimestamp = genesisTimestamp.Add(time.Second) require.Error(t, fp.Verify(vsLater)) }) @@ -214,7 +229,7 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs0 := genesisViewSpec(rng, ep) proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() lQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -254,7 +269,7 @@ func TestProposalVerifyRejectsViewMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a valid proposal at genesis view (0, 0). - vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs0 := genesisViewSpec(rng, ep) leader0 := leaderKey(committee, keys, vs0.View()) fp := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -269,7 +284,7 @@ func TestProposalVerifyRejectsForgedSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) // Build two valid proposals with different timestamps. @@ -286,7 +301,7 @@ func TestProposalVerifyRejectsWrongProposer(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) correctLeader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -313,7 +328,7 @@ func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} // no timeoutQC + vs := genesisViewSpec(rng, ep) // no timeoutQC proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -339,7 +354,7 @@ func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -371,7 +386,7 @@ func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -403,7 +418,7 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -435,7 +450,7 @@ func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -473,7 +488,7 @@ func TestProposalVerifyRejectsMissingLaneQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -495,7 +510,7 @@ func TestProposalVerifyRejectsLaneQCBlockNumberMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -519,7 +534,7 @@ func TestProposalVerifyRejectsInvalidLaneQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -567,7 +582,7 @@ func TestProposalVerifyRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testi time.Now(), []*LaneRange{NewLaneRange(lane, 0, utils.Some(NewBlock(lane, MaxLaneRangeInProposal, GenBlockHeaderHash(rng), GenPayload(rng)).Header()))}, utils.None[*AppProposal](), - ep.FirstBlock(), + 1, ) require.Error(t, oversized.Verify(ep)) } @@ -626,13 +641,13 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} // no previous commitQC, so app starts at None + vs := genesisViewSpec(rng, ep) // no previous commitQC, so app starts at None leader := leaderKey(committee, keys, vs.View()) 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()) + appQC := makeAppQCFor(keys, vs.GenesisFirstBlock, 0, GenAppHash(rng), ep.EpochIndex()) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: fp.laneQCs, @@ -719,8 +734,8 @@ func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) const split RoadIndex = 1 - prev := NewEpoch(0, RoadRange{First: 0, Next: split}, time.Time{}, committee, 1) - current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 1) + prev := NewEpoch(0, RoadRange{First: 0, Next: split}, committee) + current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, committee) qc0 := BuildCommitQC(prev, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) // Same-epoch App on the justifying tipcut so BuildCommitQC keeps it (Prev absent there). priorApp := makeAppQCFor(keys, qc0.GlobalRange().Next-1, qc0.Proposal().Index(), GenAppHash(rng), 1) @@ -799,7 +814,7 @@ func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -828,7 +843,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{Epochs: EpochDuoForTest(ep)} + vs0 := genesisViewSpec(rng, ep) leader0 := leaderKey(committee, keys, vs0.View()) lane := committee.Leader(vs0.View()) laneQC0 := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -849,7 +864,7 @@ func TestProposalVerifyValidReproposal(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuoForTest(ep)} + vs1 := withTimeoutQC(vs0, timeoutQC) require.Equal(t, View{Index: 0, Number: 1, EpochIndex: ep.EpochIndex()}, vs1.View()) leader1 := leaderKey(committee, keys, vs1.View()) @@ -866,7 +881,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs0 := genesisViewSpec(rng, ep) leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -882,7 +897,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuoForTest(ep)} + vs1 := withTimeoutQC(vs0, timeoutQC) leader1 := leaderKey(committee, keys, vs1.View()) // Create a valid reproposal, then tamper it with unnecessary laneQCs. @@ -905,7 +920,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs0 := genesisViewSpec(rng, ep) leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -921,7 +936,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epochs: EpochDuoForTest(ep)} + vs1 := withTimeoutQC(vs0, timeoutQC) leader1 := leaderKey(committee, keys, vs1.View()) // Build the valid reproposal, then tamper its timestamp to get a different hash. @@ -970,10 +985,10 @@ func TestViewSpecViewStampsEpochIndex(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) epochIdx := EpochIndex(7) - ep := NewEpoch(epochIdx, RoadRange{First: 1, Next: utils.Max[RoadIndex]()}, time.Time{}, committee, 0) + ep := NewEpoch(epochIdx, RoadRange{First: 1, Next: utils.Max[RoadIndex]()}, committee) // Without TimeoutQC: epoch index must come from vs.Epoch(). - vs0 := ViewSpec{Epochs: EpochDuoForTest(ep)} + vs0 := genesisViewSpec(rng, ep) if got := vs0.View().EpochIndex; got != epochIdx { t.Fatalf("no-TimeoutQC path: EpochIndex = %d, want %d", got, epochIdx) } @@ -982,7 +997,7 @@ func TestViewSpecViewStampsEpochIndex(t *testing.T) { tqc := NewTimeoutQC([]*FullTimeoutVote{ NewFullTimeoutVote(keys[0], View{EpochIndex: 0}, utils.None[*PrepareQC]()), }) - vs1 := ViewSpec{TimeoutQC: utils.Some(tqc), Epochs: EpochDuoForTest(ep)} + vs1 := withTimeoutQC(vs0, tqc) 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 8d4f972211..1a20be78fb 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -26,9 +26,7 @@ func EpochDuoForTest(current *Epoch) EpochDuo { prev := NewEpoch( current.EpochIndex()-1, RoadRange{First: 0, Next: first}, - time.Time{}, current.Committee(), - 1, ) return NewEpochDuo(current, utils.Some(prev)) } @@ -310,8 +308,8 @@ func GenEpochIndex(rng utils.Rng) EpochIndex { } // GenEpochWithCommittee returns a random Epoch wrapping committee. -// epochIndex, firstBlock, timestamp, and Roads.First are randomized so that tests -// exercise epoch-binding checks rather than silently passing on zero values. +// epochIndex and Roads.First are randomized so that tests exercise +// epoch-binding checks rather than silently passing on zero values. // When epochIndex > 0, First is at least 1 so EpochDuoForTest can place Prev. func GenEpochWithCommittee(rng utils.Rng, committee *Committee) *Epoch { idx := GenEpochIndex(rng) @@ -322,9 +320,7 @@ func GenEpochWithCommittee(rng utils.Rng, committee *Committee) *Epoch { return NewEpoch( idx, RoadRange{First: first, Next: first + RoadIndex(rng.Uint64()%10000) + 11}, - utils.GenTimestamp(rng), committee, - GlobalBlockNumber(rng.Uint64()%1000000)+1, ) } @@ -351,22 +347,24 @@ func GenProposalAt(rng utils.Rng, view View) *Proposal { // 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. +// binding rather than real lane/app data. GlobalRange starts at 1. func ProposalAt(ep *Epoch, view View) *Proposal { view.EpochIndex = ep.EpochIndex() 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()) + return newProposal(view, time.Time{}, []*LaneRange{NewLaneRange(lane, 0, utils.Some(header))}, utils.None[*AppProposal](), 1) } -// 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. +// GenProposalForEpoch generates a Proposal at a specific view whose epochIndex +// and lane IDs are consistent with ep. Use in tests that verify QCs against a +// known Epoch. GlobalRange.First is randomized (floors live on ViewSpec/Registry, +// not Epoch). func GenProposalForEpoch(rng utils.Rng, ep *Epoch, view View) *Proposal { view.EpochIndex = ep.EpochIndex() c := ep.Committee() laneRanges := utils.GenSlice(rng, func(rng utils.Rng) *LaneRange { return GenLaneRangeFor(rng, c) }) - return newProposal(view, utils.GenTimestamp(rng), laneRanges, utils.Some(GenAppProposal(rng)), ep.FirstBlock()) + firstBlock := GlobalBlockNumber(rng.Uint64()%1000000) + 1 + return newProposal(view, utils.GenTimestamp(rng), laneRanges, utils.Some(GenAppProposal(rng)), firstBlock) } // GenAppHash generates a random AppHash. diff --git a/sei-tendermint/autobahn/types/types_test.go b/sei-tendermint/autobahn/types/types_test.go index b82de8462c..de05d0076d 100644 --- a/sei-tendermint/autobahn/types/types_test.go +++ b/sei-tendermint/autobahn/types/types_test.go @@ -144,7 +144,7 @@ func TestTimeoutQCConvDecode_EmptyVotesReturnsError(t *testing.T) { func TestNewTimeoutQC_MixedPrepareQCs(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) + ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), committee) view := View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()} pqc := makePrepareQC(keys, NewPrepareVote(ProposalAt(ep, view))) @@ -174,7 +174,7 @@ func TestNewTimeoutQC_MixedPrepareQCs(t *testing.T) { func TestNewTimeoutQC_AllNone(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) + ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), committee) view := View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()} votes := make([]*FullTimeoutVote, len(keys)) @@ -196,7 +196,7 @@ func TestNewTimeoutQC_AllNone(t *testing.T) { func TestTimeoutQCVerify_HighestPrepareQCSelected(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) + ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), committee) view := View{Index: 0, Number: 5, EpochIndex: ep.EpochIndex()} makePQCAt := func(vn ViewNumber) *PrepareQC { diff --git a/sei-tendermint/autobahn/types/wireguard_test.go b/sei-tendermint/autobahn/types/wireguard_test.go index f59e0ae9b8..d1b7f0278e 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{Epochs: NewEpochDuo(NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 0), utils.None[*Epoch]())}, + ViewSpec{Epochs: NewEpochDuo(NewEpoch(0, OpenRoadRange(), committee), utils.None[*Epoch]())}, time.Unix(1, 2), laneQCs, utils.None[*AppQC](), diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 7fa280d52f..61c1900560 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -2,7 +2,6 @@ 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" @@ -13,7 +12,7 @@ func makeVoteEpoch(idx types.EpochIndex, weights map[types.PublicKey]uint64) *ty c := utils.OrPanic1(types.NewCommittee(weights)) first := types.RoadIndex(uint64(idx) * 108_000) rr := types.RoadRange{First: first, Next: first + 108_000} - return types.NewEpoch(idx, rr, time.Time{}, c, 0) + return types.NewEpoch(idx, rr, c) } func TestLaneVoteSet_Add(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index ef7ffb9302..cf8b63f5fc 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -103,7 +103,7 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. if startEpochDuo.Current.EpochIndex() > 0 { return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } - i.appVotes.prune(startEpochDuo.Current.FirstBlock()) + i.appVotes.prune(registry.FirstBlock()) return i, nil } @@ -131,9 +131,9 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. ls.persistedBlockStart = anchor.CommitQC.LaneRange(lane).First() } } 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(startEpochDuo.Current.FirstBlock()) + // No anchor: floor appVotes at genesis (registry), not tip Current — + // live advanceEpoch also leaves appVotes at the genesis floor. + i.appVotes.prune(registry.FirstBlock()) } else { return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index b640322884..c9ce358baa 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -856,8 +856,8 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { } // 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). +// appVotes must be floored by the prune-anchor CommitQC, not registry genesis +// FirstBlock (queue.prune only advances; a too-high bootstrap would stick). func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) @@ -885,8 +885,8 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { inner, err := newInner(registry, loaded.nextCommitQC(), 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, inner.epochDuo.Load().Current.FirstBlock(), inner.appVotes.first) + "appVotes must follow prune-anchor GlobalRange, not registry.FirstBlock") + require.NotEqual(t, registry.FirstBlock(), inner.appVotes.first) } func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index db9907fd7a..17b13a6c6b 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -379,7 +379,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, Epochs: types.NewEpochDuo(types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, initialBlock), utils.None[*types.Epoch]())} + vs := types.ViewSpec{CommitQC: prev, Epochs: types.NewEpochDuo(types.NewEpoch(0, types.OpenRoadRange(), committee), utils.None[*types.Epoch]())} fullProposal := utils.OrPanic1(types.NewProposal( leaderKey(committee, keys, vs.View()), vs, @@ -484,8 +484,7 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { for _, k := range keys[1:] { weights[k.Public()] = 1000 } - ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, - ep0.FirstTimestamp(), utils.OrPanic1(types.NewCommittee(weights)), ep0.FirstBlock()) + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, utils.OrPanic1(types.NewCommittee(weights))) duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) errCh := make(chan error, 1) @@ -525,8 +524,7 @@ func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { vote := types.Sign(keys[0], types.NewLaneVote(header)) weights := map[types.PublicKey]uint64{keys[0].Public(): 1000, keys[1].Public(): 1000} - ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, - ep0.FirstTimestamp(), utils.OrPanic1(types.NewCommittee(weights)), ep0.FirstBlock()) + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, utils.OrPanic1(types.NewCommittee(weights))) duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) errCh := make(chan error, 1) @@ -1021,7 +1019,7 @@ func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { // PushAppVote parks in waitForCommitQC (not on the epoch window). epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) proposal := types.NewAppProposal( - epM.FirstBlock(), epM.RoadRange().First, types.GenAppHash(rng), epM.EpochIndex()) + registry.FirstBlock(), epM.RoadRange().First, types.GenAppHash(rng), epM.EpochIndex()) vote := types.Sign(keys[0], types.NewAppVote(proposal)) ctx, cancel := context.WithCancel(t.Context()) cancel() diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 8d14797ce9..b0a0e84a24 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -94,12 +94,14 @@ const innerFile = "inner" // inner holds no registry: the epoch window is provided from outside (newInner / // pushCommitQC on State), and epoch transitions are explicit. +// Genesis floors for ViewSpec come from State.registry (see State.viewSpec). type inner struct { persistedInner epochs types.EpochDuo } // View returns the current view, embedding the epoch's index. +// Genesis floors are unused here (View only needs CommitQC/TimeoutQC/Epochs). func (i inner) View() types.View { vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epochs: i.epochs} return vs.View() diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index 2d9228aad1..af470ab2f0 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -4,7 +4,6 @@ import ( "os" "path/filepath" "testing" - "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" @@ -22,7 +21,7 @@ func testCommitQC( laneQCs map[types.LaneID]*types.LaneQC, appQC utils.Option[*types.AppQC], ) *types.CommitQC { - ep := types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, 0) + ep := types.NewEpoch(0, types.OpenRoadRange(), committee) return types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) } diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 44cb2d2ea7..fc30274a8d 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -95,7 +95,12 @@ func (p *persistedInner) validate(commitEp *types.Epoch, viewDuo types.EpochDuo) } } - vs := types.ViewSpec{CommitQC: p.CommitQC, TimeoutQC: p.TimeoutQC, Epochs: viewDuo} + vs := types.ViewSpec{ + CommitQC: p.CommitQC, + TimeoutQC: p.TimeoutQC, + Epochs: viewDuo, + GenesisFirstBlock: 0, // validate does not use NextGlobalBlock; floors unused here + } 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 c0ed5503b8..7eb0cfc069 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -115,11 +115,12 @@ func newState( } innerSend := utils.Alloc(utils.NewAtomicSend(initialInner)) + registry := data.Registry() s := &State{ cfg: cfg, // metrics: NewMetrics(), avail: availState, - registry: data.Registry(), + registry: registry, inner: utils.NewMutex(innerSend), innerRecv: innerSend.Subscribe(), persister: pers, @@ -128,7 +129,13 @@ func newState( prepareVotes: utils.NewMutex(newPrepareVotes()), commitVotes: utils.NewMutex(newCommitVotes()), - myView: utils.NewAtomicSend(types.ViewSpec{CommitQC: initialInner.CommitQC, TimeoutQC: initialInner.TimeoutQC, Epochs: initialInner.epochs}), + myView: utils.NewAtomicSend(types.ViewSpec{ + CommitQC: initialInner.CommitQC, + TimeoutQC: initialInner.TimeoutQC, + Epochs: initialInner.epochs, + GenesisFirstBlock: registry.FirstBlock(), + GenesisTimestamp: registry.FirstTimestamp(), + }), myProposal: utils.NewAtomicSend(utils.None[*types.FullProposal]()), myPrepareVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqPrepareVote]()), myCommitVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqCommitVote]()), @@ -303,6 +310,18 @@ func updateOutput[T types.ConsensusReq](w *utils.AtomicSend[utils.Option[T]], v } } +// viewSpec builds a ViewSpec for i, taking genesis floors from the registry +// (used only when CommitQC is None). +func (s *State) viewSpec(i inner) types.ViewSpec { + return types.ViewSpec{ + CommitQC: i.CommitQC, + TimeoutQC: i.TimeoutQC, + Epochs: i.epochs, + GenesisFirstBlock: s.registry.FirstBlock(), + GenesisTimestamp: s.registry.FirstTimestamp(), + } +} + // Updates the outputs based on the inner state. // Persists state to disk before broadcasting votes to ensure votes are durable // before dissemination (prevents double-voting on crash). @@ -310,7 +329,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, Epochs: i.epochs} + vs := s.viewSpec(i) old := s.myView.Load() if old.View().Less(vs.View()) { s.myView.Store(vs) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 8519fd3794..fb1c1779a1 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -88,7 +88,7 @@ func newInner(firstBlock types.GlobalBlockNumber) *inner { } // skipTo advances all cursors to n, discarding everything before it. -// Used on recovery when the first loaded QC starts past committee.FirstBlock() +// Used on recovery when the first loaded QC starts past registry.FirstBlock() // (i.e. data before n was pruned in a previous run). func (i *inner) skipTo(n types.GlobalBlockNumber) { i.first = n diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index eae147275e..274ccfb6f2 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -51,6 +51,9 @@ type registryState struct { // lag data (peer FullCommitQC / async BlockDB flush); catch-up from peers // and avail LastCommitQC in Run closes the gap. // - Placeholders use the genesis committee until real committees are wired. +// - Genesis FirstBlock / FirstTimestamp live on Registry (from GenDoc), not +// on Epoch — per-epoch floors come from CommitQCs, which the registry does +// not store. // // TODO(autobahn): replace genesis placeholders with epoch info on blocks. type Registry struct { @@ -58,6 +61,10 @@ type Registry struct { // epochGen bumps on every new registration; WaitForDuo waits on it so // filling a gap still wakes waiters. epochGen utils.AtomicSend[uint64] + // Genesis floors from GenDoc (InitialHeight / GenesisTime). + genesisFirstBlock types.GlobalBlockNumber + genesisTimestamp time.Time + genesisCommittee *types.Committee } // NewRegistry creates a Registry with genesis epoch 0 only. @@ -67,12 +74,15 @@ func NewRegistry( firstBlock types.GlobalBlockNumber, genesisTimestamp time.Time, ) (*Registry, error) { - ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, genesisTimestamp, committee, firstBlock) + ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) return &Registry{ state: utils.NewRWMutex(®istryState{ m: map[types.EpochIndex]*types.Epoch{0: ep}, }), - epochGen: utils.NewAtomicSend(uint64(0)), + epochGen: utils.NewAtomicSend(uint64(0)), + genesisFirstBlock: firstBlock, + genesisTimestamp: genesisTimestamp, + genesisCommittee: committee, }, nil } @@ -113,12 +123,14 @@ func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) erro return nil } -// FirstBlock returns the first global block number of the genesis epoch. +// FirstBlock returns the genesis global block floor (GenDoc.InitialHeight). func (r *Registry) FirstBlock() types.GlobalBlockNumber { - for s := range r.state.RLock() { - return s.m[0].FirstBlock() - } - panic("unreachable") + return r.genesisFirstBlock +} + +// FirstTimestamp returns the genesis timestamp (GenDoc.GenesisTime). +func (r *Registry) FirstTimestamp() time.Time { + return r.genesisTimestamp } // EpochAt returns the epoch containing roadIndex. @@ -137,12 +149,11 @@ func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { // makeEpoch inserts a genesis-committee placeholder at epochIdx. // Caller holds the write lock. Overwrites if present. Panics without epoch 0. func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types.Epoch { - genesis, ok := s.m[0] - if !ok { + if _, ok := s.m[0]; !ok { panic("genesis epoch missing from registry") } firstRoad := FirstRoad(epochIdx) - epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)}, genesis.FirstTimestamp(), genesis.Committee(), genesis.FirstBlock()) + epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)}, r.genesisCommittee) s.m[epochIdx] = epoch r.epochGen.Store(r.epochGen.Load() + 1) return epoch diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index ef5860d271..742836930e 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -184,7 +184,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: FirstRoad(1)}, time.Time{}, committee, 0) + ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) bare := &Registry{ state: utils.NewRWMutex(®istryState{ m: map[types.EpochIndex]*types.Epoch{0: ep}, @@ -201,8 +201,8 @@ func TestDuoAt_ErrorWhenPrevMissing(t *testing.T) { committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ types.GenSecretKey(utils.TestRng()).Public(): 1, })) - ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, time.Time{}, committee, 0) - ep2 := types.NewEpoch(2, types.RoadRange{First: FirstRoad(2), Next: FirstRoad(3)}, time.Time{}, committee, 0) + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) + ep2 := types.NewEpoch(2, types.RoadRange{First: FirstRoad(2), Next: FirstRoad(3)}, committee) // Gap: epoch 2 present without epoch 1. bare := &Registry{ state: utils.NewRWMutex(®istryState{ From aebafda5a51f85398e28034420395621c37d1b3b Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 10:44:29 -0700 Subject: [PATCH 54/85] Fix NewEpoch call sites in sei-db after Epoch floor removal. Co-authored-by: Cursor --- sei-db/ledger_db/block/block_db_test.go | 4 +--- sei-db/ledger_db/block/blocksim/block_generator.go | 10 +++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/sei-db/ledger_db/block/block_db_test.go b/sei-db/ledger_db/block/block_db_test.go index 7b8bde798b..8a64f1e051 100644 --- a/sei-db/ledger_db/block/block_db_test.go +++ b/sei-db/ledger_db/block/block_db_test.go @@ -1484,8 +1484,6 @@ const ( testSeed = 20260615 ) -var genesisTime = time.Unix(1_700_000_000, 0) - // batch is a contiguous run of blocks at global numbers [first, next) together // with the QC that finalizes them. next == first+len(blocks). type batch struct { @@ -1577,7 +1575,7 @@ func buildFullCommitQC( } else { appQC = utils.None[*types.AppQC]() } - ep := types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0) + ep := types.NewEpoch(0, types.OpenRoadRange(), committee) cqc := types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) return types.NewFullCommitQC(cqc, headers), blockList } diff --git a/sei-db/ledger_db/block/blocksim/block_generator.go b/sei-db/ledger_db/block/blocksim/block_generator.go index b9f1a3c35f..ba6b3c11d3 100644 --- a/sei-db/ledger_db/block/blocksim/block_generator.go +++ b/sei-db/ledger_db/block/blocksim/block_generator.go @@ -168,7 +168,15 @@ func (g *BlockGenerator) buildFullCommitQC() (*types.FullCommitQC, []*types.Bloc } } - viewSpec := types.ViewSpec{CommitQC: prev, Epochs: types.NewEpochDuo(types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0), utils.None[*types.Epoch]())} + viewSpec := types.ViewSpec{ + CommitQC: prev, + Epochs: types.NewEpochDuo( + types.NewEpoch(0, types.OpenRoadRange(), committee), + utils.None[*types.Epoch](), + ), + GenesisFirstBlock: 0, + GenesisTimestamp: genesisTime, + } leader := committee.Leader(viewSpec.View()) // AppQC certifies the prior tipcut (road == prev.Index), not the tipcut // being built (View.Index). buildProposal clears road >= view. From e29102823b02c53be2ac8e240f374682494e0268 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 10:57:33 -0700 Subject: [PATCH 55/85] Wait for in-window AppQC before tipcut propose; reject stale fallback. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 14 +++-- .../autobahn/types/proposal_test.go | 7 ++- sei-tendermint/autobahn/types/testonly.go | 44 +++++++++++++++ .../internal/autobahn/avail/state.go | 54 +++++++++++++++++-- .../internal/autobahn/consensus/state.go | 34 +++++++++--- 5 files changed, 132 insertions(+), 21 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 100d793dc6..21c7ea683d 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -368,15 +368,17 @@ func buildProposal( app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } - // AppQC must be Current or Current-1. Outside that window, drop the - // candidate and keep the prior CommitQC App (no new appQC). + // AppQC must be Current or Current-1. Callers (consensus runPropose) must + // wait for an in-window App before constructing a tipcut when Current>0 — + // do not silently fall back to a stale CommitQC App. // Use cur-1 (not appEp+1) so uint64 wrap cannot admit MaxUint64 when cur==0. if a, ok := app.Get(); ok { appEp, cur := a.EpochIndex(), viewSpec.Epoch().EpochIndex() if appEp != cur && (cur == 0 || appEp != cur-1) { - app = AppOpt(ProposalOpt(viewSpec.CommitQC)) - appQC = utils.None[*AppQC]() + return nil, appQC, fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEp, cur) } + } else if viewSpec.Epoch().EpochIndex() > 0 { + return nil, appQC, fmt.Errorf("App required for epoch %d tipcut (need Current or Current-1)", viewSpec.Epoch().EpochIndex()) } // Normalize the creation timestamp. if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { @@ -540,7 +542,9 @@ func (m *FullProposal) Verify(vs ViewSpec) error { if appEpoch != cur { prev, ok := vs.Epochs.Prev.Get() if !ok { - return fmt.Errorf("appQC epoch %d needs Prev, but Prev is absent (Current %d)", appEpoch, cur) + // NewEpochDuo requires Prev iff Current>0; AppQC at Current-1 + // with Current>0 must have Prev. + panic(fmt.Sprintf("appQC epoch %d needs Prev, but Prev is absent (Current %d)", appEpoch, cur)) } ep = prev } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 2bc9ef3e45..54fdc0c8db 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -724,10 +724,9 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { require.True(t, ok) require.Equal(t, EpochIndex(0), appPrev.EpochIndex()) - // AppQC outside {Current, Current-1} — cleared; tipcut keeps CommitQC App. - fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2)))) - require.False(t, fpWrong.appQC.IsPresent()) - require.NoError(t, fpWrong.Verify(vs)) + // AppQC outside {Current, Current-1} — rejected (no silent fallback). + _, err := NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2))) + require.Error(t, err) } func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index 1a20be78fb..36c1df8681 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -35,6 +35,10 @@ func EpochDuoForTest(current *Epoch) EpochDuo { // If laneQCs is empty, a single 1-block LaneQC is synthesized so the tipcut is // non-empty (empty tipcuts are rejected by Proposal.Verify). // Use BuildFullCommitQC when you want random blocks generated automatically. +// +// For epoch>0, NewProposal requires an in-window App (Current or Current-1). If +// neither appQC nor prev's CommitQC App provides one, a Current-1 AppQC is +// synthesized on the prior tipcut road so fixtures keep working. func BuildCommitQC( epoch *Epoch, keys []SecretKey, @@ -46,6 +50,16 @@ func BuildCommitQC( if len(laneQCs) == 0 { laneQCs = oneBlockLaneQCMap(vs, keys) } + if epoch.EpochIndex() > 0 { + have := appInDuoWindow(AppOpt(ProposalOpt(prev)), epoch.EpochIndex()) + if qc, ok := appQC.Get(); ok { + ep := qc.Proposal().EpochIndex() + have = have || ep == epoch.EpochIndex() || ep == epoch.EpochIndex()-1 + } + if !have { + appQC = utils.Some(synthAppQCForPrevEpoch(epoch, keys, prev)) + } + } leader := epoch.Committee().Leader(vs.View()) var leaderKey SecretKey for _, k := range keys { @@ -62,6 +76,36 @@ func BuildCommitQC( return NewCommitQC(votes) } +func appInDuoWindow(app utils.Option[*AppProposal], want EpochIndex) bool { + a, ok := app.Get() + if !ok { + return false + } + ep := a.EpochIndex() + if ep == want { + return true + } + return want > 0 && ep == want-1 +} + +// synthAppQCForPrevEpoch builds a Current-1 AppQC anchored on the prior tipcut +// (or genesis global 0 when prev is absent). +func synthAppQCForPrevEpoch(epoch *Epoch, keys []SecretKey, prev utils.Option[*CommitQC]) *AppQC { + prevEp := epoch.EpochIndex() - 1 + var global GlobalBlockNumber + var road RoadIndex + if cqc, ok := prev.Get(); ok { + global = cqc.GlobalRange().Next - 1 + road = cqc.Proposal().Index() + } + p := NewAppProposal(global, road, AppHash{}, prevEp) + votes := make([]*Signed[*AppVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, Sign(k, NewAppVote(p))) + } + return NewAppQC(votes) +} + // oneBlockLaneQCMap builds a single LaneQC advancing the first committee lane by one block. func oneBlockLaneQCMap(vs ViewSpec, keys []SecretKey) map[LaneID]*LaneQC { c := vs.Epoch().Committee() diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 667a8b8021..0d5201dc2c 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -312,21 +312,67 @@ func (s *State) LastAppQC() utils.Option[*types.AppQC] { panic("unreachable") } +// appEpochInDuo reports whether app's epoch is want or want-1 (Prev lag). +// want==0 only accepts epoch 0. +func appEpochInDuo(appEp, want types.EpochIndex) bool { + if appEp == want { + return true + } + return want > 0 && appEp == want-1 +} + // LastAppQCInEpochDuo returns LastAppQC when its epoch is usable for a tipcut -// in want (want or want-1). Otherwise None so the proposer omits a new AppQC -// and keeps the prior CommitQC App — including when AppQC is ahead of want. +// in want (want or want-1). Otherwise None. func (s *State) LastAppQCInEpochDuo(want types.EpochIndex) utils.Option[*types.AppQC] { appQC, ok := s.LastAppQC().Get() if !ok { return utils.None[*types.AppQC]() } - appEp := appQC.Proposal().EpochIndex() - if appEp == want || (want > 0 && appEp == want-1) { + if appEpochInDuo(appQC.Proposal().EpochIndex(), want) { return utils.Some(appQC) } return utils.None[*types.AppQC]() } +// WaitForAppQCInEpochDuo returns an AppQC usable for a tipcut in want (want or +// want-1). For want==0, returns LastAppQCInEpochDuo immediately (may be None). +// +// For want>0, proposing must not fall back to a CommitQC App older than want-1. +// If commitQC already carries an in-window App, returns LastAppQCInEpochDuo +// without waiting (None is fine: tipcut keeps that CommitQC App). Otherwise +// blocks until latestAppQC is in-window. +func (s *State) WaitForAppQCInEpochDuo( + ctx context.Context, + want types.EpochIndex, + commitQC utils.Option[*types.CommitQC], +) (utils.Option[*types.AppQC], error) { + if want == 0 { + return s.LastAppQCInEpochDuo(want), nil + } + if old, ok := types.AppOpt(types.ProposalOpt(commitQC)).Get(); ok && appEpochInDuo(old.EpochIndex(), want) { + return s.LastAppQCInEpochDuo(want), nil + } + for inner, ctrl := range s.inner.Lock() { + ready := func() bool { + appQC, ok := inner.latestAppQC.Get() + return ok && appEpochInDuo(appQC.Proposal().EpochIndex(), want) + } + if !ready() { + logger.Warn("waiting for AppQC in EpochDuo before proposing", + slog.Uint64("want_epoch", uint64(want))) + if err := ctrl.WaitUntil(ctx, ready); err != nil { + return utils.None[*types.AppQC](), err + } + } + appQC, ok := inner.latestAppQC.Get() + if !ok || !appEpochInDuo(appQC.Proposal().EpochIndex(), want) { + return utils.None[*types.AppQC](), fmt.Errorf("WaitForAppQCInEpochDuo: AppQC not in duo after wait") + } + return utils.Some(appQC), nil + } + panic("unreachable") +} + // WaitForAppQC waits until there is an AppQC for the given index or higher. // Returns this AppQC and the corresponding CommitQC. // Together they provide enough information to prune the availability state. diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 7eb0cfc069..dcb9cf642e 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -269,8 +269,8 @@ 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() { - return nil // not the leader. + if !s.shouldPropose(vs) { + return nil } // Try repropose. if fullProposal, ok := types.NewReproposal(s.cfg.Key, vs); ok { @@ -282,27 +282,45 @@ func (s *State) runPropose(ctx context.Context) error { 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() { + // Waits can outlive this view (timeout / new tipcut / lost leadership). + if !s.shouldPropose(vs) || ep.EpochIndex() != vs.Epoch().EpochIndex() { + return nil + } + // For Current>0, do not propose until App is within {Current, Current-1} + // (CommitQC App or live AppQC). Omitting an out-of-window AppQC and + // falling back to a stale CommitQC App is not allowed. + appQC, err := s.avail.WaitForAppQCInEpochDuo(ctx, vs.Epoch().EpochIndex(), vs.CommitQC) + if err != nil { + return fmt.Errorf("s.avail.WaitForAppQCInEpochDuo(): %w", err) + } + if !s.shouldPropose(vs) { return nil } - // Construct a full proposal. fullProposal, err := types.NewProposal( s.cfg.Key, vs, time.Now(), laneQCsMap, - s.avail.LastAppQCInEpochDuo(vs.Epoch().EpochIndex()), + appQC, ) if err != nil { - return fmt.Errorf("s.avail.WaitForProposal(): %w", err) + return fmt.Errorf("types.NewProposal(): %w", err) } s.myProposal.Store(utils.Some(fullProposal)) return nil }) } +// shouldPropose is true when vs is still the live view and we are its leader. +// Re-check after any avail wait: the view may have advanced while we blocked. +func (s *State) shouldPropose(vs types.ViewSpec) bool { + cur := s.myView.Load() + if cur.View() != vs.View() { + return false + } + return cur.Epoch().Committee().Leader(cur.View()) == s.cfg.Key.Public() +} + func updateOutput[T types.ConsensusReq](w *utils.AtomicSend[utils.Option[T]], v T) { old := w.Load() if !v.View().Less(types.NextViewOpt(old)) { From c1c6afe8fc79280dfa80864a8e15710f8f15a342 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 11:12:11 -0700 Subject: [PATCH 56/85] Park PushVote until Current accepts the signer. Lane-vote streams are committee-only; without parking, async epoch entry drops votes permanently (no p2p retry). Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 39 ++++++++---- .../internal/autobahn/avail/state_test.go | 60 +++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 0d5201dc2c..9a1a412387 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -627,9 +627,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()) } - // Snapshot Current once for off-lock verify. Unlike PushVote, we do not - // re-check membership after WaitUntil — lane proposals are not reweighted - // across epoch advances (genesis committees are stable today). + // Snapshot Current once for off-lock verify. Unlike PushVote (which parks + // until Current accepts the signer), we do not wait for future committees — + // lane proposals are not reweighted across epoch advances. duo := s.epochDuo.Load() c := duo.Current.Committee() if err := p.Msg().Verify(c); err != nil { @@ -679,22 +679,35 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos return nil } -// PushVote verifies against Current, then under lock waits for lane capacity and -// credits with the live duo (drop if Current advanced and signer left). -// Signers not in Current fail immediately (no park) so a peer stream cannot be -// wedged on unauthenticated input; future-committee admission is a follow-up -// once per-epoch committees are real. +// PushVote parks until Current can accept the vote (signer weight + voted lane), +// verifies, then under lock waits for capacity and credits with the live duo +// (drop if Current advanced and signer left). +// +// Lane-vote streams are committee-only (giga RunServer/RunClient), so parking a +// future-epoch signer does not expose an unauthenticated DoS path. No p2p retry: +// without this wait, async epoch entry would drop the vote permanently. func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { h := vote.Msg().Header() - duo := s.epochDuo.Load() - c := duo.Current.Committee() - if err := vote.Msg().Verify(c); err != nil { + // Future-epoch voters park (one stream goroutine) until Current includes them. + var committee *types.Committee + var verifiedEpoch types.EpochIndex + for inner, ctrl := range s.inner.Lock() { + if err := ctrl.WaitUntil(ctx, func() bool { + c := inner.epochDuo.Load().Current.Committee() + return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) + }); err != nil { + return err + } + duo := inner.epochDuo.Load() + committee = duo.Current.Committee() + verifiedEpoch = duo.Current.EpochIndex() + } + if err := vote.Msg().Verify(committee); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } - if err := vote.VerifySig(c); err != nil { + if err := vote.VerifySig(committee); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } - verifiedEpoch := duo.Current.EpochIndex() for inner, ctrl := range s.inner.Lock() { ls, ok := inner.lanes[h.Lane()] if !ok { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 17b13a6c6b..6a31af9c27 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -463,6 +463,66 @@ func TestWaitForAppQC(t *testing.T) { require.ErrorIs(t, state.waitForAppQC(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) } +// TestPushVote_WaitsForFutureEpochSigner: a voter not yet in Current parks until +// advanceEpoch installs a committee that includes them, then credits. +func TestPushVote_WaitsForFutureEpochSigner(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + ep0 := state.epochDuo.Load().Current + futureKey := types.GenSecretKey(rng) + lane := futureKey.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(futureKey, types.NewLaneVote(header)) + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() // parked: Weight==0 under Current + + weights := map[types.PublicKey]uint64{ + futureKey.Public(): 1000, + keys[1].Public(): 1000, + } + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, + utils.OrPanic1(types.NewCommittee(weights))) + duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(duo1) + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + ls, ok := inner.lanes[lane] + require.True(t, ok) + require.Contains(t, ls.votes.q[0].byKey, futureKey.Public()) + } + }) +} + +// TestPushVote_FutureEpochSignerParks: canceled ctx while signer is not in +// Current returns Canceled (park), not a verify error. +func TestPushVote_FutureEpochSignerParks(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + futureKey := types.GenSecretKey(rng) + lane := futureKey.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(futureKey, types.NewLaneVote(header)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + require.ErrorIs(t, state.PushVote(ctx, vote), context.Canceled) +} + // TestPushVote_DropsSignerAfterEpochAdvance: after verify, capacity WaitUntil // releases the lock; advanceEpoch installs a Current that excludes the signer — // the vote must be dropped (Weight==0). From ffa0484887adec1a6a4ddff6630e51c5b8777881 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 11:53:01 -0700 Subject: [PATCH 57/85] Index competing headers in byHash after LaneQC for reconstruction. Co-authored-by: Cursor --- .../internal/autobahn/avail/block_votes.go | 14 +++++++++----- .../internal/autobahn/avail/block_votes_test.go | 8 +++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 0c31e006b9..d1e959e0db 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -53,17 +53,21 @@ func (bv *blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVo } bv.byKey[k] = vote - // One QC per block: still retain the vote for reweight, but stop growing sets. - if bv.qc.IsPresent() { - return utils.None[*types.LaneQC]() - } - + // Always index the header in byHash so headers() can reconstruct committed + // chains even if this vote arrives after a LaneQC for a competing hash. h := vote.Msg().Header().Hash() set, ok := bv.byHash[h] if !ok { set = &laneVoteSet{header: vote.Msg().Header()} bv.byHash[h] = set } + + // One QC per block: keep byKey/byHash for reweight and header lookup, but + // do not form or replace a second LaneQC. + if bv.qc.IsPresent() { + return utils.None[*types.LaneQC]() + } + if !set.add(w, ep.Committee().LaneQuorum(), vote) { 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 61c1900560..509c437e92 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -178,13 +178,15 @@ func TestPushVote_OneQCPerBlockVotes(t *testing.T) { require.True(t, ok) require.Equal(t, header1.Hash(), got.Header().Hash()) - // Competing hash is retained in byKey for reweight but must not grow byHash - // or replace the single QC. + // Competing hash is indexed in byHash (headers() reconstruction) and byKey + // (reweight), but must not replace the single LaneQC. require.False(t, bv.pushVote(ep, types.Sign(keyC, types.NewLaneVote(header2))).IsPresent()) require.False(t, bv.pushVote(ep, types.Sign(keyD, types.NewLaneVote(header2))).IsPresent()) require.Contains(t, bv.byKey, keyC.Public()) require.Contains(t, bv.byKey, keyD.Public()) - require.NotContains(t, bv.byHash, header2.Hash()) + require.Contains(t, bv.byHash, header2.Hash()) + require.Equal(t, header2, bv.byHash[header2.Hash()].header) + require.Empty(t, bv.byHash[header2.Hash()].votes, "post-QC competing votes are not credited toward a second QC") got, ok = bv.qc.Get() require.True(t, ok) require.Equal(t, header1.Hash(), got.Header().Hash()) From e4786d1647325b5e46a1bed62bb620ecb54ce8f0 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 13:27:25 -0700 Subject: [PATCH 58/85] Deduplicate AppQC window, seal, and road-wait helpers. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch.go | 11 ++ sei-tendermint/autobahn/types/epoch_test.go | 25 ++++ sei-tendermint/autobahn/types/proposal.go | 19 ++- .../internal/autobahn/avail/state.go | 115 +++++++++--------- .../internal/autobahn/avail/state_test.go | 12 +- .../internal/autobahn/consensus/state.go | 2 +- .../internal/autobahn/epoch/registry.go | 9 +- 7 files changed, 111 insertions(+), 82 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index 2b4263571b..ca7a51bcee 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -46,3 +46,14 @@ func NewEpoch(index EpochIndex, roads RoadRange, committee *Committee) *Epoch { func (e *Epoch) EpochIndex() EpochIndex { return e.epochIndex } func (e *Epoch) RoadRange() RoadRange { return e.roads } func (e *Epoch) Committee() *Committee { return e.committee } + +// AcceptsAppEpoch reports whether appEp is usable for a tipcut whose Current is +// e: e itself, or e-1 (Prev lag). Epoch 0 accepts only epoch 0. +// Use e-1 (not appEp+1) so uint64 wrap cannot admit MaxUint64 when e==0. +func (e *Epoch) AcceptsAppEpoch(appEp EpochIndex) bool { + cur := e.epochIndex + if appEp == cur { + return true + } + return cur > 0 && appEp == cur-1 +} diff --git a/sei-tendermint/autobahn/types/epoch_test.go b/sei-tendermint/autobahn/types/epoch_test.go index db91d7fa73..4cbb8f857f 100644 --- a/sei-tendermint/autobahn/types/epoch_test.go +++ b/sei-tendermint/autobahn/types/epoch_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) func TestRoadRange_Has(t *testing.T) { @@ -21,3 +23,26 @@ func TestRoadRange_IsLastRoad(t *testing.T) { require.True(t, r.IsLastRoad(12)) require.False(t, r.IsLastRoad(13)) } + +func TestEpoch_AcceptsAppEpoch(t *testing.T) { + rng := utils.TestRng() + weights := map[PublicKey]uint64{GenSecretKey(rng).Public(): 1} + committee := utils.OrPanic1(NewCommittee(weights)) + cases := []struct { + cur, app EpochIndex + ok bool + }{ + {0, 0, true}, + {0, 1, false}, + {0, ^EpochIndex(0), false}, // MaxUint64 must not count as cur-1 + {1, 1, true}, + {1, 0, true}, + {1, 2, false}, + {2, 1, true}, + {2, 0, false}, + } + for _, tc := range cases { + ep := NewEpoch(tc.cur, RoadRange{First: 0, Next: 1}, committee) + require.Equal(t, tc.ok, ep.AcceptsAppEpoch(tc.app), "Epoch(%d).AcceptsAppEpoch(%d)", tc.cur, tc.app) + } +} diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 21c7ea683d..00cc7456c7 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -371,11 +371,10 @@ func buildProposal( // AppQC must be Current or Current-1. Callers (consensus runPropose) must // wait for an in-window App before constructing a tipcut when Current>0 — // do not silently fall back to a stale CommitQC App. - // Use cur-1 (not appEp+1) so uint64 wrap cannot admit MaxUint64 when cur==0. if a, ok := app.Get(); ok { - appEp, cur := a.EpochIndex(), viewSpec.Epoch().EpochIndex() - if appEp != cur && (cur == 0 || appEp != cur-1) { - return nil, appQC, fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEp, cur) + appEp := a.EpochIndex() + if !viewSpec.Epoch().AcceptsAppEpoch(appEp) { + return nil, appQC, fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEp, viewSpec.Epoch().EpochIndex()) } } else if viewSpec.Epoch().EpochIndex() > 0 { return nil, appQC, fmt.Errorf("App required for epoch %d tipcut (need Current or Current-1)", viewSpec.Epoch().EpochIndex()) @@ -524,11 +523,9 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } else { app, _ := m.proposal.Msg().App().Get() appEpoch := app.EpochIndex() - cur := vs.Epoch().EpochIndex() - // Allow Current or Current-1 (Prev lag). Reject anything else. - // Use cur-1 (not appEpoch+1) so uint64 wrap cannot admit MaxUint64 when cur==0. - if appEpoch != cur && (cur == 0 || appEpoch != cur-1) { - return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEpoch, cur) + cur := vs.Epoch() + if !cur.AcceptsAppEpoch(appEpoch) { + return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEpoch, cur.EpochIndex()) } appQC, ok := m.appQC.Get() if !ok { @@ -539,12 +536,12 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } s.Spawn(func() error { ep := vs.Epochs.Current - if appEpoch != cur { + if appEpoch != cur.EpochIndex() { prev, ok := vs.Epochs.Prev.Get() if !ok { // NewEpochDuo requires Prev iff Current>0; AppQC at Current-1 // with Current>0 must have Prev. - panic(fmt.Sprintf("appQC epoch %d needs Prev, but Prev is absent (Current %d)", appEpoch, cur)) + panic(fmt.Sprintf("appQC epoch %d needs Prev, but Prev is absent (Current %d)", appEpoch, cur.EpochIndex())) } ep = prev } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 9a1a412387..7a25d8e59c 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -229,9 +229,17 @@ func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { slog.Uint64("road", uint64(roadIdx)), "duo", duo.String()) } -// waitForEpoch waits until roadIdx is in Prev|Current. ErrPruned if behind window. -func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { +// waitForRoad waits until roadIdx is admitted. If currentOnly, only Current +// counts (CommitQC tip); otherwise Prev|Current (AppVote/AppQC). ErrPruned if +// the road is behind the relevant window. +func (s *State) waitForRoad(ctx context.Context, roadIdx types.RoadIndex, currentOnly bool) (types.EpochDuo, error) { duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { + if currentOnly { + if duo.Current.RoadRange().Has(roadIdx) { + return true + } + return roadIdx < duo.Current.RoadRange().First + } if _, err := duo.EpochForRoad(roadIdx); err == nil { return true } @@ -244,27 +252,36 @@ func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (type if err != nil { return types.EpochDuo{}, err } - if _, err := duo.EpochForRoad(roadIdx); err == nil { + if currentOnly { + if duo.Current.RoadRange().Has(roadIdx) { + return duo, nil + } + } else if _, err := duo.EpochForRoad(roadIdx); err == nil { return duo, nil } return types.EpochDuo{}, types.ErrPruned } -// waitCurrentForRoad waits until roadIdx is in Current. ErrPruned if behind Current. -func (s *State) waitCurrentForRoad(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, 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 types.EpochDuo{}, err +// sealNextDuoIfLastRoad runs AppQC + WaitForDuo leashes when idx closes ep. +// incoming is passed to waitForAppQC (None for CommitQC-only seal). Same call +// path as Push*; not an async seal thread. +func (s *State) sealNextDuoIfLastRoad( + ctx context.Context, + ep *types.Epoch, + idx types.RoadIndex, + incoming utils.Option[*types.AppQC], +) (utils.Option[types.EpochDuo], error) { + if !ep.RoadRange().IsLastRoad(idx) { + return utils.None[types.EpochDuo](), nil } - if duo.Current.RoadRange().Has(roadIdx) { - return duo, nil + if err := s.waitForAppQC(ctx, ep.EpochIndex(), incoming); err != nil { + return utils.None[types.EpochDuo](), err } - return types.EpochDuo{}, types.ErrPruned + nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) + if err != nil { + return utils.None[types.EpochDuo](), err + } + return utils.Some(nt), nil } // waitForAppQC blocks until latest AppQC is from epochIdx or later. @@ -312,30 +329,22 @@ func (s *State) LastAppQC() utils.Option[*types.AppQC] { panic("unreachable") } -// appEpochInDuo reports whether app's epoch is want or want-1 (Prev lag). -// want==0 only accepts epoch 0. -func appEpochInDuo(appEp, want types.EpochIndex) bool { - if appEp == want { - return true - } - return want > 0 && appEp == want-1 -} - // LastAppQCInEpochDuo returns LastAppQC when its epoch is usable for a tipcut -// in want (want or want-1). Otherwise None. -func (s *State) LastAppQCInEpochDuo(want types.EpochIndex) utils.Option[*types.AppQC] { +// whose Current is want. Otherwise None. +func (s *State) LastAppQCInEpochDuo(want *types.Epoch) utils.Option[*types.AppQC] { appQC, ok := s.LastAppQC().Get() if !ok { return utils.None[*types.AppQC]() } - if appEpochInDuo(appQC.Proposal().EpochIndex(), want) { + if want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) { return utils.Some(appQC) } return utils.None[*types.AppQC]() } -// WaitForAppQCInEpochDuo returns an AppQC usable for a tipcut in want (want or -// want-1). For want==0, returns LastAppQCInEpochDuo immediately (may be None). +// WaitForAppQCInEpochDuo returns an AppQC usable for a tipcut whose Current is +// want (want or want-1). For epoch 0, returns LastAppQCInEpochDuo immediately +// (may be None). // // For want>0, proposing must not fall back to a CommitQC App older than want-1. // If commitQC already carries an in-window App, returns LastAppQCInEpochDuo @@ -343,29 +352,29 @@ func (s *State) LastAppQCInEpochDuo(want types.EpochIndex) utils.Option[*types.A // blocks until latestAppQC is in-window. func (s *State) WaitForAppQCInEpochDuo( ctx context.Context, - want types.EpochIndex, + want *types.Epoch, commitQC utils.Option[*types.CommitQC], ) (utils.Option[*types.AppQC], error) { - if want == 0 { + if want.EpochIndex() == 0 { return s.LastAppQCInEpochDuo(want), nil } - if old, ok := types.AppOpt(types.ProposalOpt(commitQC)).Get(); ok && appEpochInDuo(old.EpochIndex(), want) { + if old, ok := types.AppOpt(types.ProposalOpt(commitQC)).Get(); ok && want.AcceptsAppEpoch(old.EpochIndex()) { return s.LastAppQCInEpochDuo(want), nil } for inner, ctrl := range s.inner.Lock() { ready := func() bool { appQC, ok := inner.latestAppQC.Get() - return ok && appEpochInDuo(appQC.Proposal().EpochIndex(), want) + return ok && want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) } if !ready() { logger.Warn("waiting for AppQC in EpochDuo before proposing", - slog.Uint64("want_epoch", uint64(want))) + slog.Uint64("want_epoch", uint64(want.EpochIndex()))) if err := ctrl.WaitUntil(ctx, ready); err != nil { return utils.None[*types.AppQC](), err } } appQC, ok := inner.latestAppQC.Get() - if !ok || !appEpochInDuo(appQC.Proposal().EpochIndex(), want) { + if !ok || !want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) { return utils.None[*types.AppQC](), fmt.Errorf("WaitForAppQCInEpochDuo: AppQC not in duo after wait") } return utils.Some(appQC), nil @@ -417,7 +426,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - duo, err := s.waitCurrentForRoad(ctx, idx) + duo, err := s.waitForRoad(ctx, idx, true) if err != nil { if errors.Is(err, types.ErrPruned) { logStaleRoad("CommitQC", idx, s.epochDuo.Load()) @@ -431,16 +440,9 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { } // Boundary: switch to the next epoch on Current's last CommitQC. - nextDuo := utils.None[types.EpochDuo]() - if ep.RoadRange().IsLastRoad(idx) { - if err := s.waitForAppQC(ctx, ep.EpochIndex(), utils.None[*types.AppQC]()); err != nil { - return err - } - nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) - if err != nil { - return err - } - nextDuo = utils.Some(nt) + nextDuo, err := s.sealNextDuoIfLastRoad(ctx, ep, idx, utils.None[*types.AppQC]()) + if err != nil { + return err } for inner, ctrl := range s.inner.Lock() { @@ -469,7 +471,7 @@ 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. - duo, err := s.waitForEpoch(ctx, idx) + duo, err := s.waitForRoad(ctx, idx, false) if err != nil { if errors.Is(err, types.ErrPruned) { logStaleRoad("AppVote", idx, s.epochDuo.Load()) @@ -527,7 +529,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } } - // Pair consistency only; ahead-of-window still waits in waitForEpoch. + // Pair consistency only; ahead-of-window still waits in waitForRoad. if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) } @@ -538,7 +540,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return fmt.Errorf("appQC GlobalNumber not in commitQC range") } idx := commitQC.Proposal().Index() - duo, err := s.waitForEpoch(ctx, idx) + duo, err := s.waitForRoad(ctx, idx, false) if err != nil { if errors.Is(err, types.ErrPruned) { logStaleRoad("AppQC", idx, s.epochDuo.Load()) @@ -555,16 +557,9 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ } // Same seal leashes as PushCommitQC when this tipcut is Current's last road. // Pass this AppQC as incoming so a tipcut that first enters epoch N can close N. - nextDuo := utils.None[types.EpochDuo]() - if ep.RoadRange().IsLastRoad(idx) { - if err := s.waitForAppQC(ctx, ep.EpochIndex(), utils.Some(appQC)); err != nil { - return err - } - nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) - if err != nil { - return err - } - nextDuo = utils.Some(nt) + nextDuo, err := s.sealNextDuoIfLastRoad(ctx, ep, idx, utils.Some(appQC)) + if err != nil { + return err } for inner, ctrl := range s.inner.Lock() { updated, err := inner.prune(appQC, commitQC) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 6a31af9c27..4f2cd514bc 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1105,10 +1105,10 @@ func TestPushAppVoteFarFutureParks(t *testing.T) { require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) } -// TestWaitCurrentForRoadPrevNotAdmitted: a road in Prev is too late for -// Current-only admission (CommitQC), even though waitForEpoch would +// TestWaitForRoad_CurrentOnlyVsDuo: a road in Prev is too late for +// Current-only admission (CommitQC), even though duo admission would // still resolve it. -func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { +func TestWaitForRoad_CurrentOnlyVsDuo(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) ds := newTestDataState(&data.Config{Registry: registry}) @@ -1118,10 +1118,10 @@ func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { registerDuoAtEpoch(state, m) // Prev=M-1|Current=M roadInPrev := epoch.FirstRoad(m - 1) - _, err = state.waitForEpoch(t.Context(), roadInPrev) + _, err = state.waitForRoad(t.Context(), roadInPrev, false) require.NoError(t, err, "Prev|Current window still covers Prev roads") - _, err = state.waitCurrentForRoad(t.Context(), roadInPrev) + _, err = state.waitForRoad(t.Context(), roadInPrev, true) require.ErrorIs(t, err, types.ErrPruned, "Current-only wait must treat Prev roads as too late") } @@ -1533,7 +1533,7 @@ func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { registerDuoAtEpoch(state, m-1) // Satisfy waitForCommitQC(FirstRoad(m)-1) without pushing EpochLength QCs. - // Current remains M-1, so FirstRoad(m) is too early for waitCurrentForRoad. + // Current remains M-1, so FirstRoad(m) is too early for waitForRoad(..., true). tipQC := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ EpochIndex: m - 1, diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index dcb9cf642e..72f0b44622 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -289,7 +289,7 @@ func (s *State) runPropose(ctx context.Context) error { // For Current>0, do not propose until App is within {Current, Current-1} // (CommitQC App or live AppQC). Omitting an out-of-window AppQC and // falling back to a stale CommitQC App is not allowed. - appQC, err := s.avail.WaitForAppQCInEpochDuo(ctx, vs.Epoch().EpochIndex(), vs.CommitQC) + appQC, err := s.avail.WaitForAppQCInEpochDuo(ctx, vs.Epoch(), vs.CommitQC) if err != nil { return fmt.Errorf("s.avail.WaitForAppQCInEpochDuo(): %w", err) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 274ccfb6f2..fab7f5c057 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -110,10 +110,11 @@ func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) erro } } r.EnsureDuoAt(span.Next) - // Placeholder +1/+2: simplification while committees are genesis stubs - // (unchanged by exec). Covers exec tip ahead of persisted CommitQC (N+1) - // and tip at LastRoad(N) without re-exec (N+2). Goes away next PR when - // committees are linked to execution. + // TODO(autobahn-placeholder-seed): always seed windowLast+1/+2 with + // genesis-committee stubs. Needed today because exec tip can sit ahead + // of persisted CommitQC (N+1) and tip at LastRoad(N) may need N+2 + // without re-exec. Drop once real committees are linked to execution + // and seed tipcut+1 only. r.EnsureEpoch(windowLast + 1) r.EnsureEpoch(windowLast + 2) return nil From 97b80e551c89a7f2119c8a52c13409eab501ab38 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 13:32:20 -0700 Subject: [PATCH 59/85] Narrow tipcut AppQC wait API; keep in-window comments. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 1 + .../internal/autobahn/avail/state.go | 34 ++++++++----------- .../internal/autobahn/consensus/state.go | 4 +-- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 00cc7456c7..d8313b5b29 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -524,6 +524,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { app, _ := m.proposal.Msg().App().Get() appEpoch := app.EpochIndex() cur := vs.Epoch() + // Allow Current or Current-1 (Prev lag). Reject anything else. if !cur.AcceptsAppEpoch(appEpoch) { return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEpoch, cur.EpochIndex()) } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 7a25d8e59c..287755d2c1 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -263,8 +263,7 @@ func (s *State) waitForRoad(ctx context.Context, roadIdx types.RoadIndex, curren } // sealNextDuoIfLastRoad runs AppQC + WaitForDuo leashes when idx closes ep. -// incoming is passed to waitForAppQC (None for CommitQC-only seal). Same call -// path as Push*; not an async seal thread. +// incoming is passed to waitForAppQC (None for CommitQC-only seal). func (s *State) sealNextDuoIfLastRoad( ctx context.Context, ep *types.Epoch, @@ -329,37 +328,34 @@ func (s *State) LastAppQC() utils.Option[*types.AppQC] { panic("unreachable") } -// LastAppQCInEpochDuo returns LastAppQC when its epoch is usable for a tipcut -// whose Current is want. Otherwise None. -func (s *State) LastAppQCInEpochDuo(want *types.Epoch) utils.Option[*types.AppQC] { +// tipcutAppQC returns LastAppQC when its epoch is usable for a tipcut whose +// Current is want (want or want-1). Otherwise None. +func (s *State) tipcutAppQC(want *types.Epoch) utils.Option[*types.AppQC] { appQC, ok := s.LastAppQC().Get() - if !ok { + if !ok || !want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) { return utils.None[*types.AppQC]() } - if want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) { - return utils.Some(appQC) - } - return utils.None[*types.AppQC]() + return utils.Some(appQC) } -// WaitForAppQCInEpochDuo returns an AppQC usable for a tipcut whose Current is -// want (want or want-1). For epoch 0, returns LastAppQCInEpochDuo immediately +// WaitForTipcutAppQC returns an AppQC usable for a tipcut whose Current is +// want (want or want-1). For epoch 0, returns tipcutAppQC immediately // (may be None). // // For want>0, proposing must not fall back to a CommitQC App older than want-1. -// If commitQC already carries an in-window App, returns LastAppQCInEpochDuo -// without waiting (None is fine: tipcut keeps that CommitQC App). Otherwise -// blocks until latestAppQC is in-window. -func (s *State) WaitForAppQCInEpochDuo( +// If commitQC already carries an in-window App, returns tipcutAppQC without +// waiting (None is fine: tipcut keeps that CommitQC App). Otherwise blocks +// until latestAppQC is in-window. +func (s *State) WaitForTipcutAppQC( ctx context.Context, want *types.Epoch, commitQC utils.Option[*types.CommitQC], ) (utils.Option[*types.AppQC], error) { if want.EpochIndex() == 0 { - return s.LastAppQCInEpochDuo(want), nil + return s.tipcutAppQC(want), nil } if old, ok := types.AppOpt(types.ProposalOpt(commitQC)).Get(); ok && want.AcceptsAppEpoch(old.EpochIndex()) { - return s.LastAppQCInEpochDuo(want), nil + return s.tipcutAppQC(want), nil } for inner, ctrl := range s.inner.Lock() { ready := func() bool { @@ -375,7 +371,7 @@ func (s *State) WaitForAppQCInEpochDuo( } appQC, ok := inner.latestAppQC.Get() if !ok || !want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) { - return utils.None[*types.AppQC](), fmt.Errorf("WaitForAppQCInEpochDuo: AppQC not in duo after wait") + return utils.None[*types.AppQC](), fmt.Errorf("WaitForTipcutAppQC: AppQC not in duo after wait") } return utils.Some(appQC), nil } diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 72f0b44622..a75fb7e562 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -289,9 +289,9 @@ func (s *State) runPropose(ctx context.Context) error { // For Current>0, do not propose until App is within {Current, Current-1} // (CommitQC App or live AppQC). Omitting an out-of-window AppQC and // falling back to a stale CommitQC App is not allowed. - appQC, err := s.avail.WaitForAppQCInEpochDuo(ctx, vs.Epoch(), vs.CommitQC) + appQC, err := s.avail.WaitForTipcutAppQC(ctx, vs.Epoch(), vs.CommitQC) if err != nil { - return fmt.Errorf("s.avail.WaitForAppQCInEpochDuo(): %w", err) + return fmt.Errorf("s.avail.WaitForTipcutAppQC(): %w", err) } if !s.shouldPropose(vs) { return nil From b3512c9b74446abec18007f1c4901d3f5212907c Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 13:47:54 -0700 Subject: [PATCH 60/85] Share waitRoadOrDropStale for Push* admit soft-drops. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 287755d2c1..3158da43ad 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -262,6 +262,26 @@ func (s *State) waitForRoad(ctx context.Context, roadIdx types.RoadIndex, curren return types.EpochDuo{}, types.ErrPruned } +// waitRoadOrDropStale waits until roadIdx is admitted for Push* paths. If the +// road is behind the window, logs a stale drop and returns None (soft-drop). +// currentOnly matches waitForRoad: CommitQC tip vs Prev|Current for AppVote/AppQC. +func (s *State) waitRoadOrDropStale( + ctx context.Context, + what string, + roadIdx types.RoadIndex, + currentOnly bool, +) (utils.Option[types.EpochDuo], error) { + duo, err := s.waitForRoad(ctx, roadIdx, currentOnly) + if err != nil { + if errors.Is(err, types.ErrPruned) { + logStaleRoad(what, roadIdx, s.epochDuo.Load()) + return utils.None[types.EpochDuo](), nil + } + return utils.None[types.EpochDuo](), err + } + return utils.Some(duo), nil +} + // sealNextDuoIfLastRoad runs AppQC + WaitForDuo leashes when idx closes ep. // incoming is passed to waitForAppQC (None for CommitQC-only seal). func (s *State) sealNextDuoIfLastRoad( @@ -422,14 +442,14 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - duo, err := s.waitForRoad(ctx, idx, true) + admitted, err := s.waitRoadOrDropStale(ctx, "CommitQC", idx, true) if err != nil { - if errors.Is(err, types.ErrPruned) { - logStaleRoad("CommitQC", idx, s.epochDuo.Load()) - return nil - } return err } + duo, ok := admitted.Get() + if !ok { + return nil + } ep := duo.Current if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) @@ -467,14 +487,14 @@ 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. - duo, err := s.waitForRoad(ctx, idx, false) + admitted, err := s.waitRoadOrDropStale(ctx, "AppVote", idx, false) if err != nil { - if errors.Is(err, types.ErrPruned) { - logStaleRoad("AppVote", idx, s.epochDuo.Load()) - return nil - } return err } + duo, ok := admitted.Get() + if !ok { + return nil + } ep := utils.OrPanic1(duo.EpochForRoad(idx)) if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want { return fmt.Errorf("appVote epoch_index %d, want %d", got, want) @@ -536,14 +556,14 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return fmt.Errorf("appQC GlobalNumber not in commitQC range") } idx := commitQC.Proposal().Index() - duo, err := s.waitForRoad(ctx, idx, false) + admitted, err := s.waitRoadOrDropStale(ctx, "AppQC", idx, false) if err != nil { - if errors.Is(err, types.ErrPruned) { - logStaleRoad("AppQC", idx, s.epochDuo.Load()) - return nil - } return err } + duo, ok := admitted.Get() + if !ok { + return nil + } ep := utils.OrPanic1(duo.EpochForRoad(idx)) if err := appQC.Verify(ep); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) From bf0b1d3e8bc12b2a936de30ef656aad03aff6e53 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 13:51:52 -0700 Subject: [PATCH 61/85] Move road admit classification onto EpochDuo. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 42 +++++++++++++++++++ .../autobahn/types/epoch_duo_test.go | 37 ++++++++++++++++ .../internal/autobahn/avail/state.go | 28 ++++--------- 3 files changed, 87 insertions(+), 20 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 0ab8ef298c..d9e99f2758 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -40,6 +40,48 @@ func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { var ErrRoadBeforeWindow = errors.New("road before epoch duo window") var ErrRoadAfterWindow = errors.New("road after epoch duo window") +// RoadStatus classifies a road relative to a Prev|Current window for admit waits. +type RoadStatus int + +const ( + RoadReady RoadStatus = iota // in the admitted window + RoadStale // behind the window (soft-drop / ErrPruned) + RoadFuture // ahead of the window (backpressure wait) +) + +// Contains reports whether roadIdx is in Prev|Current. +func (w EpochDuo) Contains(roadIdx RoadIndex) bool { + _, err := w.EpochForRoad(roadIdx) + return err == nil +} + +// ContainsCurrent reports whether roadIdx is in Current only. +func (w EpochDuo) ContainsCurrent(roadIdx RoadIndex) bool { + return w.Current.RoadRange().Has(roadIdx) +} + +// RoadStatus classifies roadIdx for admit waits. If currentOnly, only Current +// admits (CommitQC tip); otherwise Prev|Current (AppVote/AppQC). +func (w EpochDuo) RoadStatus(roadIdx RoadIndex, currentOnly bool) RoadStatus { + if currentOnly { + if w.ContainsCurrent(roadIdx) { + return RoadReady + } + if roadIdx < w.Current.RoadRange().First { + return RoadStale + } + return RoadFuture + } + _, err := w.EpochForRoad(roadIdx) + if err == nil { + return RoadReady + } + if errors.Is(err, ErrRoadBeforeWindow) { + return RoadStale + } + return RoadFuture +} + // EpochForRoad returns the epoch containing roadIdx. // Window is [Prev.First or Current.First, Current.Next). Outside → // ErrRoadBeforeWindow / ErrRoadAfterWindow. Under contiguous Prev|Current there diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 8daacfd11b..0778a3932c 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -111,3 +111,40 @@ func TestEpochForRoad(t *testing.T) { }) } } + +func TestEpochDuo_RoadStatus(t *testing.T) { + prev, current := testDuoEpochs(t) + withPrev := types.NewEpochDuo(current, utils.Some(prev)) + ep0Only := types.NewEpochDuo(prev, utils.None[*types.Epoch]()) + + for _, tc := range []struct { + name string + w types.EpochDuo + road types.RoadIndex + currentOnly bool + want types.RoadStatus + }{ + {"duo_prev_ready", withPrev, 50, false, types.RoadReady}, + {"duo_prev_stale_as_current", withPrev, 50, true, types.RoadStale}, + {"duo_current_ready", withPrev, 150, false, types.RoadReady}, + {"duo_current_ready_current_only", withPrev, 150, true, types.RoadReady}, + {"duo_future", withPrev, 200, false, types.RoadFuture}, + {"duo_future_current_only", withPrev, 200, true, types.RoadFuture}, + {"ep0_ready", ep0Only, 50, false, types.RoadReady}, + {"ep0_future", ep0Only, 100, false, types.RoadFuture}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.w.RoadStatus(tc.road, tc.currentOnly); got != tc.want { + t.Fatalf("RoadStatus(%d, currentOnly=%v) = %v, want %v", + tc.road, tc.currentOnly, got, tc.want) + } + if tc.currentOnly { + if tc.w.ContainsCurrent(tc.road) != (tc.want == types.RoadReady) { + t.Fatalf("ContainsCurrent(%d) inconsistent with status %v", tc.road, tc.want) + } + } else if tc.w.Contains(tc.road) != (tc.want == types.RoadReady) { + t.Fatalf("Contains(%d) inconsistent with status %v", tc.road, tc.want) + } + }) + } +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 3158da43ad..63cb66fcb3 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -234,32 +234,20 @@ func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { // the road is behind the relevant window. func (s *State) waitForRoad(ctx context.Context, roadIdx types.RoadIndex, currentOnly bool) (types.EpochDuo, error) { duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { - if currentOnly { - if duo.Current.RoadRange().Has(roadIdx) { - return true - } - return roadIdx < duo.Current.RoadRange().First - } - if _, err := duo.EpochForRoad(roadIdx); err == nil { - return true - } - first := duo.Current.RoadRange().First - if prev, ok := duo.Prev.Get(); ok { - first = prev.RoadRange().First - } - return roadIdx < first + return duo.RoadStatus(roadIdx, currentOnly) != types.RoadFuture }) if err != nil { return types.EpochDuo{}, err } - if currentOnly { - if duo.Current.RoadRange().Has(roadIdx) { - return duo, nil - } - } else if _, err := duo.EpochForRoad(roadIdx); err == nil { + switch duo.RoadStatus(roadIdx, currentOnly) { + case types.RoadReady: return duo, nil + case types.RoadStale: + return types.EpochDuo{}, types.ErrPruned + default: + // Wait returned while still Future — should not happen. + return types.EpochDuo{}, types.ErrPruned } - return types.EpochDuo{}, types.ErrPruned } // waitRoadOrDropStale waits until roadIdx is admitted for Push* paths. If the From 13b615d9a16b97e27a3185a2350f560ff44f3227 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 14:00:52 -0700 Subject: [PATCH 62/85] Name Current vs Duo admit paths; seal AppQC on Current only. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 32 ++++------ .../autobahn/types/epoch_duo_test.go | 36 +++++------ .../internal/autobahn/avail/state.go | 61 +++++++++++++------ .../internal/autobahn/avail/state_test.go | 13 ++-- 4 files changed, 75 insertions(+), 67 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index d9e99f2758..98310562a6 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -49,29 +49,19 @@ const ( RoadFuture // ahead of the window (backpressure wait) ) -// Contains reports whether roadIdx is in Prev|Current. -func (w EpochDuo) Contains(roadIdx RoadIndex) bool { - _, err := w.EpochForRoad(roadIdx) - return err == nil -} - -// ContainsCurrent reports whether roadIdx is in Current only. -func (w EpochDuo) ContainsCurrent(roadIdx RoadIndex) bool { - return w.Current.RoadRange().Has(roadIdx) +// RoadStatusCurrent classifies roadIdx against Current only (CommitQC tip). +func (w EpochDuo) RoadStatusCurrent(roadIdx RoadIndex) RoadStatus { + if w.Current.RoadRange().Has(roadIdx) { + return RoadReady + } + if roadIdx < w.Current.RoadRange().First { + return RoadStale + } + return RoadFuture } -// RoadStatus classifies roadIdx for admit waits. If currentOnly, only Current -// admits (CommitQC tip); otherwise Prev|Current (AppVote/AppQC). -func (w EpochDuo) RoadStatus(roadIdx RoadIndex, currentOnly bool) RoadStatus { - if currentOnly { - if w.ContainsCurrent(roadIdx) { - return RoadReady - } - if roadIdx < w.Current.RoadRange().First { - return RoadStale - } - return RoadFuture - } +// RoadStatusDuo classifies roadIdx against Prev|Current (AppVote/AppQC). +func (w EpochDuo) RoadStatusDuo(roadIdx RoadIndex) RoadStatus { _, err := w.EpochForRoad(roadIdx) if err == nil { return RoadReady diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 0778a3932c..feba6aaa63 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -118,32 +118,24 @@ func TestEpochDuo_RoadStatus(t *testing.T) { ep0Only := types.NewEpochDuo(prev, utils.None[*types.Epoch]()) for _, tc := range []struct { - name string - w types.EpochDuo - road types.RoadIndex - currentOnly bool - want types.RoadStatus + name string + w types.EpochDuo + road types.RoadIndex + cur types.RoadStatus + duo types.RoadStatus }{ - {"duo_prev_ready", withPrev, 50, false, types.RoadReady}, - {"duo_prev_stale_as_current", withPrev, 50, true, types.RoadStale}, - {"duo_current_ready", withPrev, 150, false, types.RoadReady}, - {"duo_current_ready_current_only", withPrev, 150, true, types.RoadReady}, - {"duo_future", withPrev, 200, false, types.RoadFuture}, - {"duo_future_current_only", withPrev, 200, true, types.RoadFuture}, - {"ep0_ready", ep0Only, 50, false, types.RoadReady}, - {"ep0_future", ep0Only, 100, false, types.RoadFuture}, + {"prev_road", withPrev, 50, types.RoadStale, types.RoadReady}, + {"current_road", withPrev, 150, types.RoadReady, types.RoadReady}, + {"future_road", withPrev, 200, types.RoadFuture, types.RoadFuture}, + {"ep0_ready", ep0Only, 50, types.RoadReady, types.RoadReady}, + {"ep0_future", ep0Only, 100, types.RoadFuture, types.RoadFuture}, } { t.Run(tc.name, func(t *testing.T) { - if got := tc.w.RoadStatus(tc.road, tc.currentOnly); got != tc.want { - t.Fatalf("RoadStatus(%d, currentOnly=%v) = %v, want %v", - tc.road, tc.currentOnly, got, tc.want) + if got := tc.w.RoadStatusCurrent(tc.road); got != tc.cur { + t.Fatalf("RoadStatusCurrent(%d) = %v, want %v", tc.road, got, tc.cur) } - if tc.currentOnly { - if tc.w.ContainsCurrent(tc.road) != (tc.want == types.RoadReady) { - t.Fatalf("ContainsCurrent(%d) inconsistent with status %v", tc.road, tc.want) - } - } else if tc.w.Contains(tc.road) != (tc.want == types.RoadReady) { - t.Fatalf("Contains(%d) inconsistent with status %v", tc.road, tc.want) + if got := tc.w.RoadStatusDuo(tc.road); got != tc.duo { + t.Fatalf("RoadStatusDuo(%d) = %v, want %v", tc.road, got, tc.duo) } }) } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 63cb66fcb3..2ec081b27d 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -229,17 +229,19 @@ func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { slog.Uint64("road", uint64(roadIdx)), "duo", duo.String()) } -// waitForRoad waits until roadIdx is admitted. If currentOnly, only Current -// counts (CommitQC tip); otherwise Prev|Current (AppVote/AppQC). ErrPruned if -// the road is behind the relevant window. -func (s *State) waitForRoad(ctx context.Context, roadIdx types.RoadIndex, currentOnly bool) (types.EpochDuo, error) { +// waitUntilRoad waits until status is not RoadFuture; RoadStale → ErrPruned. +func (s *State) waitUntilRoad( + ctx context.Context, + roadIdx types.RoadIndex, + status func(types.EpochDuo) types.RoadStatus, +) (types.EpochDuo, error) { duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { - return duo.RoadStatus(roadIdx, currentOnly) != types.RoadFuture + return status(duo) != types.RoadFuture }) if err != nil { return types.EpochDuo{}, err } - switch duo.RoadStatus(roadIdx, currentOnly) { + switch status(duo) { case types.RoadReady: return duo, nil case types.RoadStale: @@ -250,16 +252,41 @@ func (s *State) waitForRoad(ctx context.Context, roadIdx types.RoadIndex, curren } } -// waitRoadOrDropStale waits until roadIdx is admitted for Push* paths. If the -// road is behind the window, logs a stale drop and returns None (soft-drop). -// currentOnly matches waitForRoad: CommitQC tip vs Prev|Current for AppVote/AppQC. +// waitCurrentRoad waits until roadIdx is in Current (CommitQC tip). +func (s *State) waitCurrentRoad(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { + return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { + return d.RoadStatusCurrent(roadIdx) + }) +} + +// waitDuoRoad waits until roadIdx is in Prev|Current (AppVote/AppQC). +func (s *State) waitDuoRoad(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { + return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { + return d.RoadStatusDuo(roadIdx) + }) +} + +// waitCurrentRoadOrDropStale is PushCommitQC admit: wait for Current, soft-drop if stale. +func (s *State) waitCurrentRoadOrDropStale( + ctx context.Context, what string, roadIdx types.RoadIndex, +) (utils.Option[types.EpochDuo], error) { + return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitCurrentRoad) +} + +// waitDuoRoadOrDropStale is PushAppVote/PushAppQC admit: wait for Prev|Current, soft-drop if stale. +func (s *State) waitDuoRoadOrDropStale( + ctx context.Context, what string, roadIdx types.RoadIndex, +) (utils.Option[types.EpochDuo], error) { + return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitDuoRoad) +} + func (s *State) waitRoadOrDropStale( ctx context.Context, what string, roadIdx types.RoadIndex, - currentOnly bool, + wait func(context.Context, types.RoadIndex) (types.EpochDuo, error), ) (utils.Option[types.EpochDuo], error) { - duo, err := s.waitForRoad(ctx, roadIdx, currentOnly) + duo, err := wait(ctx, roadIdx) if err != nil { if errors.Is(err, types.ErrPruned) { logStaleRoad(what, roadIdx, s.epochDuo.Load()) @@ -430,7 +457,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - admitted, err := s.waitRoadOrDropStale(ctx, "CommitQC", idx, true) + admitted, err := s.waitCurrentRoadOrDropStale(ctx, "CommitQC", idx) if err != nil { return err } @@ -475,7 +502,7 @@ 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. - admitted, err := s.waitRoadOrDropStale(ctx, "AppVote", idx, false) + admitted, err := s.waitDuoRoadOrDropStale(ctx, "AppVote", idx) if err != nil { return err } @@ -533,7 +560,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } } - // Pair consistency only; ahead-of-window still waits in waitForRoad. + // Pair consistency only; ahead-of-window still waits in waitDuoRoad. if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) } @@ -544,7 +571,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return fmt.Errorf("appQC GlobalNumber not in commitQC range") } idx := commitQC.Proposal().Index() - admitted, err := s.waitRoadOrDropStale(ctx, "AppQC", idx, false) + admitted, err := s.waitDuoRoadOrDropStale(ctx, "AppQC", idx) if err != nil { return err } @@ -559,9 +586,9 @@ 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 seal leashes as PushCommitQC when this tipcut is Current's last road. + // Seal only when this tipcut closes Current (not Prev's last road). // Pass this AppQC as incoming so a tipcut that first enters epoch N can close N. - nextDuo, err := s.sealNextDuoIfLastRoad(ctx, ep, idx, utils.Some(appQC)) + nextDuo, err := s.sealNextDuoIfLastRoad(ctx, duo.Current, idx, utils.Some(appQC)) 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 4f2cd514bc..ffb710ea42 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1105,10 +1105,9 @@ func TestPushAppVoteFarFutureParks(t *testing.T) { require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) } -// TestWaitForRoad_CurrentOnlyVsDuo: a road in Prev is too late for -// Current-only admission (CommitQC), even though duo admission would -// still resolve it. -func TestWaitForRoad_CurrentOnlyVsDuo(t *testing.T) { +// TestWaitCurrentVsDuoRoad: a road in Prev is too late for Current-only +// admission (CommitQC), even though duo admission would still resolve it. +func TestWaitCurrentVsDuoRoad(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) ds := newTestDataState(&data.Config{Registry: registry}) @@ -1118,10 +1117,10 @@ func TestWaitForRoad_CurrentOnlyVsDuo(t *testing.T) { registerDuoAtEpoch(state, m) // Prev=M-1|Current=M roadInPrev := epoch.FirstRoad(m - 1) - _, err = state.waitForRoad(t.Context(), roadInPrev, false) + _, err = state.waitDuoRoad(t.Context(), roadInPrev) require.NoError(t, err, "Prev|Current window still covers Prev roads") - _, err = state.waitForRoad(t.Context(), roadInPrev, true) + _, err = state.waitCurrentRoad(t.Context(), roadInPrev) require.ErrorIs(t, err, types.ErrPruned, "Current-only wait must treat Prev roads as too late") } @@ -1533,7 +1532,7 @@ func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { registerDuoAtEpoch(state, m-1) // Satisfy waitForCommitQC(FirstRoad(m)-1) without pushing EpochLength QCs. - // Current remains M-1, so FirstRoad(m) is too early for waitForRoad(..., true). + // Current remains M-1, so FirstRoad(m) is too early for waitCurrentRoad. tipQC := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ EpochIndex: m - 1, From bf3c44552c33611031f28ae40b122decc620f8ac Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 14:52:29 -0700 Subject: [PATCH 63/85] Share insertCommitQCAtTip for tipcut seal+insert under lock. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 16 ++++++++++++++++ .../internal/autobahn/avail/state.go | 19 ++----------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index cf8b63f5fc..df995e81ac 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -224,6 +224,22 @@ func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { i.epochDuo.Store(nextDuo) } +// insertCommitQCAtTip inserts qc at commitQCs.next. If nextDuo is set and idx +// still closes live Current, advances the duo first (same order as PushCommitQC / +// PushAppQC). Returns false if idx is not the tip (race / already applied). +func (i *inner) insertCommitQCAtTip(qc *types.CommitQC, nextDuo utils.Option[types.EpochDuo]) bool { + idx := qc.Proposal().Index() + if idx != i.commitQCs.next { + return false + } + if nd, ok := nextDuo.Get(); ok && i.epochDuo.Load().Current.RoadRange().Has(idx) { + i.advanceEpoch(nd) + } + i.commitQCs.pushBack(qc) + metrics.ObserveCommitQC(qc) + return true +} + // 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. diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 2ec081b27d..3501a6067d 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -7,7 +7,6 @@ import ( "log/slog" "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/data" pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" @@ -475,16 +474,10 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { if err != nil { return err } - for inner, ctrl := range s.inner.Lock() { - if idx != inner.commitQCs.next { + if !inner.insertCommitQCAtTip(qc, nextDuo) { return nil } - if nd, ok := nextDuo.Get(); ok { - inner.advanceEpoch(nd) - } - inner.commitQCs.pushBack(qc) - metrics.ObserveCommitQC(qc) // latestCommitQC advances only after durable persist (or no-op persister). ctrl.Updated() return nil @@ -601,15 +594,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ 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 nd, ok := nextDuo.Get(); ok && inner.epochDuo.Load().Current.RoadRange().Has(idx) { - inner.advanceEpoch(nd) - } - inner.commitQCs.pushBack(commitQC) - metrics.ObserveCommitQC(commitQC) - } + inner.insertCommitQCAtTip(commitQC, nextDuo) ctrl.Updated() } return nil From 8c9299d6123bd46a4cc5a8366621e93f1710d7aa Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 14:55:15 -0700 Subject: [PATCH 64/85] Address review nits on EpochDuo admit logging and App window checks. Log the deciding duo on stale drops, panic on impossible Future after Wait, print duo ascending, and enforce AcceptsAppEpoch on carried Apps. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 3 ++- sei-tendermint/autobahn/types/epoch_test.go | 3 +-- sei-tendermint/autobahn/types/proposal.go | 7 +++++++ .../internal/autobahn/avail/block_votes_test.go | 2 +- sei-tendermint/internal/autobahn/avail/state.go | 11 ++++++----- sei-tendermint/internal/autobahn/avail/state_test.go | 12 ++++++------ sei-tendermint/libs/utils/require/require.go | 3 +++ 7 files changed, 26 insertions(+), 15 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 98310562a6..95b07ae467 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -92,7 +92,8 @@ func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { func (w EpochDuo) String() string { s := "epochs [" sep := "" - for _, oep := range [2]utils.Option[*Epoch]{utils.Some(w.Current), w.Prev} { + // Prev then Current so a window {4,5} prints as "epochs [4, 5]". + for _, oep := range [2]utils.Option[*Epoch]{w.Prev, utils.Some(w.Current)} { if ep, ok := oep.Get(); ok { s += fmt.Sprintf("%s%d", sep, ep.EpochIndex()) sep = ", " diff --git a/sei-tendermint/autobahn/types/epoch_test.go b/sei-tendermint/autobahn/types/epoch_test.go index 4cbb8f857f..ef3b51b535 100644 --- a/sei-tendermint/autobahn/types/epoch_test.go +++ b/sei-tendermint/autobahn/types/epoch_test.go @@ -3,9 +3,8 @@ package types import ( "testing" - "github.com/stretchr/testify/require" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) func TestRoadRange_Has(t *testing.T) { diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index d8313b5b29..d9c00302e7 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -520,6 +520,13 @@ func (m *FullProposal) Verify(vs ViewSpec) error { if m.appQC.IsPresent() { return errors.New("unnecessary appQC") } + // Carried-forward App (no attached AppQC) must still be in-window. + if app, ok := m.proposal.Msg().App().Get(); ok { + if !vs.Epoch().AcceptsAppEpoch(app.EpochIndex()) { + return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", + app.EpochIndex(), vs.Epoch().EpochIndex()) + } + } } else { app, _ := m.proposal.Msg().App().Get() appEpoch := app.EpochIndex() diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 509c437e92..acbcb1305f 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -5,7 +5,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" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) func makeVoteEpoch(idx types.EpochIndex, weights map[types.PublicKey]uint64) *types.Epoch { diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 3501a6067d..a4e53de091 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -228,7 +228,8 @@ func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { slog.Uint64("road", uint64(roadIdx)), "duo", duo.String()) } -// waitUntilRoad waits until status is not RoadFuture; RoadStale → ErrPruned. +// waitUntilRoad waits until status is not RoadFuture; RoadStale → ErrPruned +// with the deciding duo still returned for logging. func (s *State) waitUntilRoad( ctx context.Context, roadIdx types.RoadIndex, @@ -244,10 +245,10 @@ func (s *State) waitUntilRoad( case types.RoadReady: return duo, nil case types.RoadStale: - return types.EpochDuo{}, types.ErrPruned + return duo, types.ErrPruned default: - // Wait returned while still Future — should not happen. - return types.EpochDuo{}, types.ErrPruned + // Wait predicate forbids Future; hitting it is an internal bug. + panic(fmt.Sprintf("waitUntilRoad: unexpected RoadFuture for road %d after Wait", roadIdx)) } } @@ -288,7 +289,7 @@ func (s *State) waitRoadOrDropStale( duo, err := wait(ctx, roadIdx) if err != nil { if errors.Is(err, types.ErrPruned) { - logStaleRoad(what, roadIdx, s.epochDuo.Load()) + logStaleRoad(what, roadIdx, duo) return utils.None[types.EpochDuo](), nil } return utils.None[types.EpochDuo](), err diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index ffb710ea42..a27ef940dc 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -434,9 +434,9 @@ func TestWaitForAppQC(t *testing.T) { 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.waitForAppQC(timeout, 0, utils.None[*types.AppQC]()), context.DeadlineExceeded) + canceled, cancel := context.WithCancel(ctx) + cancel() + require.ErrorIs(t, state.waitForAppQC(canceled, 0, utils.None[*types.AppQC]()), context.Canceled) lane := keys[0].Public() b, err := state.ProduceLocalBlock(state.NextBlock(lane), types.GenPayload(rng)) @@ -458,9 +458,9 @@ func TestWaitForAppQC(t *testing.T) { require.NoError(t, <-done) require.NoError(t, state.waitForAppQC(ctx, 0, utils.None[*types.AppQC]())) - timeout2, cancel2 := context.WithTimeout(ctx, 50*time.Millisecond) - defer cancel2() - require.ErrorIs(t, state.waitForAppQC(timeout2, 1, utils.None[*types.AppQC]()), context.DeadlineExceeded) + canceled2, cancel2 := context.WithCancel(ctx) + cancel2() + require.ErrorIs(t, state.waitForAppQC(canceled2, 1, utils.None[*types.AppQC]()), context.Canceled) } // TestPushVote_WaitsForFutureEpochSigner: a voter not yet in Current parks until diff --git a/sei-tendermint/libs/utils/require/require.go b/sei-tendermint/libs/utils/require/require.go index 14d011dec5..1c53d65416 100644 --- a/sei-tendermint/libs/utils/require/require.go +++ b/sei-tendermint/libs/utils/require/require.go @@ -26,6 +26,9 @@ var NotZero = require.NotZero // Contains . var Contains = require.Contains +// NotContains . +var NotContains = require.NotContains + func ElementsMatch[T any](t TestingT, a []T, b []T, msgAndArgs ...any) { require.ElementsMatch(t, a, b, msgAndArgs...) } From 35082720b5d44f08d1a220a758b18a00f462ed9d Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 15:25:47 -0700 Subject: [PATCH 65/85] Restore per-hash LaneQC without caching qc on the vote set. Match main: each header hash can form a QC from its votes at quorum; reweight no longer picks a single block-level winner by map order. Co-authored-by: Cursor --- .../internal/autobahn/avail/block_votes.go | 54 ++++++++++--------- .../autobahn/avail/block_votes_test.go | 48 ++++++++--------- .../internal/autobahn/avail/inner.go | 2 +- 3 files changed, 52 insertions(+), 52 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index d1e959e0db..72209e1ecf 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -17,20 +17,33 @@ func (s *laneVoteSet) reset() { s.votes = s.votes[:0] } -// add credits vote weight. Returns true if weight reached quorum (may already -// have been at/above quorum from a prior add). -func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) bool { +// add credits vote weight until quorum. Returns a newly formed LaneQC iff this +// vote crosses quorum (built from votes; not cached on the set). +func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { + if s.weight >= quorum { + 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]() + } + return utils.Some(types.NewLaneQC(s.votes)) +} + +// laneQC returns a LaneQC built from votes if weight has reached quorum. +func (s *laneVoteSet) laneQC(quorum uint64) utils.Option[*types.LaneQC] { + if s.weight < quorum { + return utils.None[*types.LaneQC]() + } + return utils.Some(types.NewLaneQC(s.votes)) } // blockVotes credits lane votes under the Current committee only. -// At most one LaneQC is retained for the block (not one per header hash). +// Each header hash may form its own LaneQC. type blockVotes struct { byKey map[types.PublicKey]*types.Signed[*types.LaneVote] byHash map[types.BlockHeaderHash]*laneVoteSet - qc utils.Option[*types.LaneQC] } func newBlockVotes() *blockVotes { @@ -53,26 +66,13 @@ func (bv *blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVo } bv.byKey[k] = vote - // Always index the header in byHash so headers() can reconstruct committed - // chains even if this vote arrives after a LaneQC for a competing hash. h := vote.Msg().Header().Hash() set, ok := bv.byHash[h] if !ok { set = &laneVoteSet{header: vote.Msg().Header()} bv.byHash[h] = set } - - // One QC per block: keep byKey/byHash for reweight and header lookup, but - // do not form or replace a second LaneQC. - if bv.qc.IsPresent() { - return utils.None[*types.LaneQC]() - } - - if !set.add(w, ep.Committee().LaneQuorum(), vote) { - return utils.None[*types.LaneQC]() - } - bv.qc = utils.Some(types.NewLaneQC(set.votes)) - return bv.qc + return set.add(w, ep.Committee().LaneQuorum(), vote) } // reweight recomputes already-stored votes under new Current after advanceEpoch. @@ -80,7 +80,6 @@ func (bv *blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVo // ctrl.Updated() after advanceEpoch (not via a return flag). func (bv *blockVotes) reweight(newEpoch *types.Epoch) { c := newEpoch.Committee() - bv.qc = utils.None[*types.LaneQC]() for _, set := range bv.byHash { set.reset() } @@ -96,12 +95,15 @@ func (bv *blockVotes) reweight(newEpoch *types.Epoch) { set = &laneVoteSet{header: vote.Msg().Header()} bv.byHash[h] = set } - if set.add(w, c.LaneQuorum(), vote) && !bv.qc.IsPresent() { - bv.qc = utils.Some(types.NewLaneQC(set.votes)) - } + set.add(w, c.LaneQuorum(), vote) } } -func (bv *blockVotes) laneQC() utils.Option[*types.LaneQC] { - return bv.qc +func (bv *blockVotes) laneQC(quorum uint64) utils.Option[*types.LaneQC] { + for _, set := range bv.byHash { + if qc, ok := set.laneQC(quorum).Get(); ok { + return utils.Some(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 acbcb1305f..bc49962c8c 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -24,20 +24,20 @@ func TestLaneVoteSet_Add(t *testing.T) { } set := &laneVoteSet{} - 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) - require.True(t, set.add(1, 2, mkVote())) + require.True(t, set.add(1, 2, mkVote()).IsPresent()) require.Equal(t, uint64(2), set.weight) require.Len(t, set.votes, 2) - require.True(t, set.add(1, 2, mkVote())) - require.Equal(t, uint64(3), set.weight) - require.Len(t, set.votes, 3) + require.False(t, set.add(1, 2, mkVote()).IsPresent(), "already at quorum: no further credit") + require.Equal(t, uint64(2), set.weight) + require.Len(t, set.votes, 2) 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) } @@ -85,13 +85,15 @@ func TestPushVote_CurrentCommitteeOnly(t *testing.T) { require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) qc, ok := bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).Get() require.True(t, ok) - gotQC, ok := bv.qc.Get() + gotQC, ok := bv.byHash[h].laneQC(ep0.Committee().LaneQuorum()).Get() require.True(t, ok) - require.Equal(t, qc, gotQC) + require.Equal(t, qc.Header().Hash(), gotQC.Header().Hash()) // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; A+B still form quorum under new weights. bv.reweight(ep1) - require.True(t, bv.qc.IsPresent()) + q := ep1.Committee().LaneQuorum() + require.True(t, bv.laneQC(q).IsPresent()) + require.True(t, bv.byHash[h].laneQC(q).IsPresent()) require.False(t, bv.pushVote(ep1, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) require.Contains(t, bv.byKey, keyE.Public()) require.Equal(t, header, bv.byHash[h].header) @@ -146,14 +148,16 @@ func TestPushVote_ReweightAfterAdvance(t *testing.T) { require.True(t, bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).IsPresent()) bv.reweight(ep1) + q := ep1.Committee().LaneQuorum() require.Equal(t, uint64(1), bv.byHash[h].weight) require.Len(t, bv.byHash[h].votes, 1) require.Equal(t, keyA.Public(), bv.byHash[h].votes[0].Key()) - require.True(t, bv.qc.IsPresent()) + require.True(t, bv.byHash[h].laneQC(q).IsPresent()) + require.True(t, bv.laneQC(q).IsPresent()) require.NotContains(t, bv.byKey, keyB.Public(), "zero-weight signer removed from byKey") } -func TestPushVote_OneQCPerBlockVotes(t *testing.T) { +func TestPushVote_CompetingHashesBothFormQC(t *testing.T) { rng := utils.TestRng() keyA := types.GenSecretKey(rng) keyB := types.GenSecretKey(rng) @@ -164,6 +168,7 @@ func TestPushVote_OneQCPerBlockVotes(t *testing.T) { ep := makeVoteEpoch(0, map[types.PublicKey]uint64{ keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, }) + q := ep.Committee().LaneQuorum() lane := keyA.Public() header1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() @@ -173,21 +178,14 @@ func TestPushVote_OneQCPerBlockVotes(t *testing.T) { bv := newBlockVotes() require.False(t, bv.pushVote(ep, types.Sign(keyA, types.NewLaneVote(header1))).IsPresent()) require.True(t, bv.pushVote(ep, types.Sign(keyB, types.NewLaneVote(header1))).IsPresent()) - require.True(t, bv.qc.IsPresent()) - got, ok := bv.qc.Get() - require.True(t, ok) - require.Equal(t, header1.Hash(), got.Header().Hash()) + require.True(t, bv.byHash[header1.Hash()].laneQC(q).IsPresent()) - // Competing hash is indexed in byHash (headers() reconstruction) and byKey - // (reweight), but must not replace the single LaneQC. + // Competing hash forms its own LaneQC; both stay. require.False(t, bv.pushVote(ep, types.Sign(keyC, types.NewLaneVote(header2))).IsPresent()) - require.False(t, bv.pushVote(ep, types.Sign(keyD, types.NewLaneVote(header2))).IsPresent()) - require.Contains(t, bv.byKey, keyC.Public()) - require.Contains(t, bv.byKey, keyD.Public()) - require.Contains(t, bv.byHash, header2.Hash()) + require.True(t, bv.pushVote(ep, types.Sign(keyD, types.NewLaneVote(header2))).IsPresent()) + require.True(t, bv.byHash[header2.Hash()].laneQC(q).IsPresent()) require.Equal(t, header2, bv.byHash[header2.Hash()].header) - require.Empty(t, bv.byHash[header2.Hash()].votes, "post-QC competing votes are not credited toward a second QC") - got, ok = bv.qc.Get() - require.True(t, ok) - require.Equal(t, header1.Hash(), got.Header().Hash()) + require.Len(t, bv.byHash[header2.Hash()].votes, 2) + require.True(t, bv.byHash[header1.Hash()].laneQC(q).IsPresent()) + require.True(t, bv.laneQC(q).IsPresent()) } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index df995e81ac..2f20457be1 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -204,7 +204,7 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ if !ok { return utils.None[*types.LaneQC]() } - return bv.laneQC() + return bv.laneQC(i.epochDuo.Load().Current.Committee().LaneQuorum()) } // advanceEpoch installs nextDuo at a boundary. Caller must ensure nextDuo is From d4ab639c4eb1d65c434e74ec70185b2df2a520fa Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 17:50:54 -0700 Subject: [PATCH 66/85] Restore nextBlockToPersist comment explaining persist cursor semantics. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/inner.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 2f20457be1..e9df340920 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -24,10 +24,18 @@ type inner struct { type laneState struct { blocks *queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] votes *queue[types.BlockNumber, *blockVotes] - // nextBlockToPersist is reconstructed from persisted blocks on restart. + // nextBlockToPersist tracks 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). // // 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 types.BlockNumber // persistedBlockStart gates PushBlock/PushVote capacity (start+BlocksPerLane). // Set from the prune-anchor LaneRange on load / after durable anchor write. From ad560e616e30749751a4c45f2b428cb27edf11c4 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 17:59:42 -0700 Subject: [PATCH 67/85] Restore trimmed main comments on errors, admit capacity, and PushBlock. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/errors.go | 14 ++++++++++++-- .../internal/autobahn/avail/inner.go | 19 ++++++++++++++----- .../internal/autobahn/avail/state.go | 3 ++- .../internal/autobahn/data/state.go | 5 ++++- .../internal/autobahn/epoch/registry.go | 3 ++- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/sei-tendermint/autobahn/types/errors.go b/sei-tendermint/autobahn/types/errors.go index e51f6a48a0..64376fd724 100644 --- a/sei-tendermint/autobahn/types/errors.go +++ b/sei-tendermint/autobahn/types/errors.go @@ -2,8 +2,10 @@ package types import "errors" -// ErrNotFound means the record is at/above the tip but not present yet -// (ahead of the contiguous prefix). Distinct from ErrPruned. +// ErrNotFound is returned when a requested record is not yet available — +// a block, QC, or AppProposal ahead of what data.State currently has (e.g. +// ahead of the contiguous block/QC prefix). Distinct from ErrPruned, which +// means the height is below the retention / eviction floor. var ErrNotFound = errors.New("not found") // ErrBlockGap is returned when the persisted blocks are not contiguous, @@ -15,7 +17,15 @@ var ErrBlockGap = errors.New("block gap in BlockDB") // GlobalBlockNumber is not exactly one greater than the previously written // block number. Blocks must be written densely ascending. var ErrBlockOutOfOrder = errors.New("block: WriteBlock out of order") + +// ErrQCNonContiguous is returned by WriteQC when the QC's GlobalRange().First +// does not equal the previous QC's GlobalRange().Next. QCs must be written as +// a contiguous, ascending sequence. var ErrQCNonContiguous = errors.New("block: WriteQC non-contiguous") + +// ErrBlockMissingQC is returned by WriteBlock when no previously written QC +// covers the block's GlobalBlockNumber. A QC covering a block must be written +// before that block (see the BlockDB ordering contract). var ErrBlockMissingQC = errors.New("block: WriteBlock without covering QC") // ErrPruned is returned when a requested record is below the current retention diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index e9df340920..8b28d9de4c 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -11,7 +11,12 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// TODO: add dynamic committee members via getOrInsertLane. +// TODO: when dynamic committee changes are supported, newly joined members +// must be added via getOrInsertLane (blocks, votes, nextBlockToPersist, and +// persistedBlockStart live on laneState). Currently lanes are initialized once +// in newInner from the start EpochDuo. BlockPersister creates lane WALs lazily +// inside MaybePruneAndPersistLane, but the new member must also appear in +// inner.lanes before the next persist cycle. type inner struct { latestAppQC utils.Option[*types.AppQC] latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] @@ -37,9 +42,11 @@ type laneState struct { // ideal. Only RecvBatch needs to be notified of cursor changes; // collectPersistBatch is in the same goroutine and reads it directly. nextBlockToPersist types.BlockNumber - // persistedBlockStart gates PushBlock/PushVote capacity (start+BlocksPerLane). - // Set from the prune-anchor LaneRange on load / after durable anchor write. - // TODO: revisit whether in-mem tipcut alone is enough (side note for another PR). + // persistedBlockStart is the block number derived from the last durably + // persisted prune anchor for this lane. 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 types.BlockNumber } @@ -146,7 +153,9 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } - // Restore CommitQCs above commitQCs.next. Epoch must already be seeded. + // Restore persisted CommitQCs. prune() may have already pushed the + // anchor's CommitQC, so skip entries below commitQCs.next. + // Epoch must already be seeded. for _, lqc := range l.commitQCs { if lqc.Index < i.commitQCs.next { continue diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index a4e53de091..fcb15bef79 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -928,7 +928,8 @@ func (s *State) Run(ctx context.Context) error { continue } if b, ok := ls.blocks.q[h.BlockNumber()]; ok { - // No need to check against headers; PushQC filters mismatches. + // 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/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index fb1c1779a1..3ac6cd3552 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -542,7 +542,10 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC return s.qcFromDB(n) } -// PushBlock requires a covering QC (or n < nextBlock → silent drop). +// PushBlock pushes block to the state. +// The QC for n must already be present (guaranteed by PushQC ordering), unless +// the height is already in the contiguous block prefix (n < nextBlock) — in +// that case the block is dropped silently (already stored or executed/evicted). // Same epochDuo before-window hard-fail as PushQC. func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block *types.Block) error { var ep *types.Epoch diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index fab7f5c057..359e45ea13 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -124,7 +124,8 @@ func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) erro return nil } -// FirstBlock returns the genesis global block floor (GenDoc.InitialHeight). +// FirstBlock returns the first global block number of the genesis epoch. +// Used as the cold-start default (no WAL, no snapshot); WAL overrides this on restart. func (r *Registry) FirstBlock() types.GlobalBlockNumber { return r.genesisFirstBlock } From 0febb3079373bbde793c3a1ebb693ed603cdf6a8 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 18:36:34 -0700 Subject: [PATCH 68/85] Rename waitCurrentRoad/waitDuoRoad to waitForEpoch/waitForEpochDuo. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 28 +++++++++---------- .../internal/autobahn/avail/state_test.go | 6 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index fcb15bef79..872803d66b 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -252,32 +252,32 @@ func (s *State) waitUntilRoad( } } -// waitCurrentRoad waits until roadIdx is in Current (CommitQC tip). -func (s *State) waitCurrentRoad(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { +// waitForEpoch waits until roadIdx is in Current (CommitQC tip). +func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { return d.RoadStatusCurrent(roadIdx) }) } -// waitDuoRoad waits until roadIdx is in Prev|Current (AppVote/AppQC). -func (s *State) waitDuoRoad(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { +// waitForEpochDuo waits until roadIdx is in Prev|Current (AppVote/AppQC). +func (s *State) waitForEpochDuo(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { return d.RoadStatusDuo(roadIdx) }) } -// waitCurrentRoadOrDropStale is PushCommitQC admit: wait for Current, soft-drop if stale. -func (s *State) waitCurrentRoadOrDropStale( +// waitForEpochOrDropStale is PushCommitQC admit: wait for Current, soft-drop if stale. +func (s *State) waitForEpochOrDropStale( ctx context.Context, what string, roadIdx types.RoadIndex, ) (utils.Option[types.EpochDuo], error) { - return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitCurrentRoad) + return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitForEpoch) } -// waitDuoRoadOrDropStale is PushAppVote/PushAppQC admit: wait for Prev|Current, soft-drop if stale. -func (s *State) waitDuoRoadOrDropStale( +// waitForEpochDuoOrDropStale is PushAppVote/PushAppQC admit: wait for Prev|Current, soft-drop if stale. +func (s *State) waitForEpochDuoOrDropStale( ctx context.Context, what string, roadIdx types.RoadIndex, ) (utils.Option[types.EpochDuo], error) { - return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitDuoRoad) + return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitForEpochDuo) } func (s *State) waitRoadOrDropStale( @@ -457,7 +457,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return err } } - admitted, err := s.waitCurrentRoadOrDropStale(ctx, "CommitQC", idx) + admitted, err := s.waitForEpochOrDropStale(ctx, "CommitQC", idx) if err != nil { return err } @@ -496,7 +496,7 @@ 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. - admitted, err := s.waitDuoRoadOrDropStale(ctx, "AppVote", idx) + admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppVote", idx) if err != nil { return err } @@ -554,7 +554,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } } - // Pair consistency only; ahead-of-window still waits in waitDuoRoad. + // Pair consistency only; ahead-of-window still waits in waitForEpochDuo. if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) } @@ -565,7 +565,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return fmt.Errorf("appQC GlobalNumber not in commitQC range") } idx := commitQC.Proposal().Index() - admitted, err := s.waitDuoRoadOrDropStale(ctx, "AppQC", idx) + admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppQC", idx) 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 a27ef940dc..ecb85f3abe 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1117,10 +1117,10 @@ func TestWaitCurrentVsDuoRoad(t *testing.T) { registerDuoAtEpoch(state, m) // Prev=M-1|Current=M roadInPrev := epoch.FirstRoad(m - 1) - _, err = state.waitDuoRoad(t.Context(), roadInPrev) + _, err = state.waitForEpochDuo(t.Context(), roadInPrev) require.NoError(t, err, "Prev|Current window still covers Prev roads") - _, err = state.waitCurrentRoad(t.Context(), roadInPrev) + _, err = state.waitForEpoch(t.Context(), roadInPrev) require.ErrorIs(t, err, types.ErrPruned, "Current-only wait must treat Prev roads as too late") } @@ -1532,7 +1532,7 @@ func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { registerDuoAtEpoch(state, m-1) // Satisfy waitForCommitQC(FirstRoad(m)-1) without pushing EpochLength QCs. - // Current remains M-1, so FirstRoad(m) is too early for waitCurrentRoad. + // Current remains M-1, so FirstRoad(m) is too early for waitForEpoch. tipQC := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ EpochIndex: m - 1, From a9922d9320a6ff1b60408176000843a0e57a342e Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 21:27:49 -0700 Subject: [PATCH 69/85] Move avail epoch seal out of Push into a Run advance loop. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 20 +-- .../internal/autobahn/avail/state.go | 112 ++++++------ .../internal/autobahn/avail/state_test.go | 165 +++++++++++++----- 3 files changed, 191 insertions(+), 106 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 8b28d9de4c..762fbbc5f6 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -224,10 +224,11 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ return bv.laneQC(i.epochDuo.Load().Current.Committee().LaneQuorum()) } -// advanceEpoch installs nextDuo at a boundary. Caller must ensure nextDuo is -// the next epoch after Current and that seal leashes (waitForAppQC, registry -// WaitForDuo) are already satisfied. Adds Current lanes; does not delete old -// lanes (TODO(lane-expiry)). +// advanceEpoch installs nextDuo at a boundary. Sole post-construction writer of +// epochDuo (via runAdvanceEpoch). Caller must ensure nextDuo is the next epoch +// after Current and that seal leashes (waitForAppQC, registry WaitForDuo) are +// already satisfied. Adds Current lanes; does not delete old lanes +// (TODO(lane-expiry)). func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { current := nextDuo.Current for lane := range current.Committee().Lanes().All() { @@ -241,17 +242,14 @@ func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { i.epochDuo.Store(nextDuo) } -// insertCommitQCAtTip inserts qc at commitQCs.next. If nextDuo is set and idx -// still closes live Current, advances the duo first (same order as PushCommitQC / -// PushAppQC). Returns false if idx is not the tip (race / already applied). -func (i *inner) insertCommitQCAtTip(qc *types.CommitQC, nextDuo utils.Option[types.EpochDuo]) bool { +// insertCommitQCAtTip inserts qc at commitQCs.next. Returns false if idx is not +// the tip (race / already applied). Does not advance epochDuo — runAdvanceEpoch +// slides the window after tip passes Current's last road (intentional gap). +func (i *inner) insertCommitQCAtTip(qc *types.CommitQC) bool { idx := qc.Proposal().Index() if idx != i.commitQCs.next { return false } - if nd, ok := nextDuo.Get(); ok && i.epochDuo.Load().Current.RoadRange().Has(idx) { - i.advanceEpoch(nd) - } i.commitQCs.pushBack(qc) metrics.ObserveCommitQC(qc) return true diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 872803d66b..9529f87a24 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -297,40 +297,16 @@ func (s *State) waitRoadOrDropStale( return utils.Some(duo), nil } -// sealNextDuoIfLastRoad runs AppQC + WaitForDuo leashes when idx closes ep. -// incoming is passed to waitForAppQC (None for CommitQC-only seal). -func (s *State) sealNextDuoIfLastRoad( - ctx context.Context, - ep *types.Epoch, - idx types.RoadIndex, - incoming utils.Option[*types.AppQC], -) (utils.Option[types.EpochDuo], error) { - if !ep.RoadRange().IsLastRoad(idx) { - return utils.None[types.EpochDuo](), nil - } - if err := s.waitForAppQC(ctx, ep.EpochIndex(), incoming); err != nil { - return utils.None[types.EpochDuo](), err - } - nt, err := s.data.Registry().WaitForDuo(ctx, idx+1) - if err != nil { - return utils.None[types.EpochDuo](), err - } - return utils.Some(nt), nil -} - // waitForAppQC blocks until latest AppQC is from epochIdx or later. -// incoming, if present, is checked off-mutex before waiting on latestAppQC. // -// Called only when sealing epoch N (last road). Epoch 0 is not special-cased: -// seal is {∅,0}→{0,1} (no Prev drop), but the leash still runs so leaving 0 -// always writes an AppQC anchor for newInner (Current>0 requires one). Do not -// reintroduce an epochIdx==0 skip: BlocksPerLane only caps local production; -// peers can PushCommitQC LastRoad(0) (then mid-epoch-1 QCs) with no local -// AppQC, which would otherwise restart without an anchor. -func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex, incoming utils.Option[*types.AppQC]) error { - if c, ok := incoming.Get(); ok && c.Proposal().EpochIndex() >= epochIdx { - return nil - } +// Called from runAdvanceEpoch when sealing epoch N (Current's last CommitQC +// already admitted). Epoch 0 is not special-cased: seal is {∅,0}→{0,1} (no Prev +// drop), but the leash still runs so leaving 0 always writes an AppQC anchor for +// newInner (Current>0 requires one). Do not reintroduce an epochIdx==0 skip: +// BlocksPerLane only caps local production; peers can PushCommitQC LastRoad(0) +// (then mid-epoch-1 QCs) with no local AppQC, which would otherwise restart +// without an anchor. +func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex) error { for inner, ctrl := range s.inner.Lock() { ready := func() bool { appQC, ok := inner.latestAppQC.Get() @@ -349,7 +325,7 @@ func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex, inc slog.Uint64("latest_app_qc_epoch", uint64(appQC.Proposal().EpochIndex())), ) } - logger.Warn("waiting for AppQC before accepting CommitQC from next epoch", attrs...) + logger.Warn("waiting for AppQC before advancing epoch", attrs...) return ctrl.WaitUntil(ctx, ready) } panic("unreachable") @@ -446,8 +422,9 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi panic("unreachable") } -// PushCommitQC admits qc for Current only (too early waits; stale drops), -// then tip-interlock leashes when sealing (last road of Current). +// PushCommitQC admits qc for Current only (too early waits; stale drops). +// Epoch slide is async in runAdvanceEpoch (tip may sit at Current.Next while +// Current still N; N+1 CommitQCs park on waitForEpoch until the duo advances). // // Admit-then-verify is intentional backpressure for ahead-of-window QCs. func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { @@ -470,13 +447,8 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return fmt.Errorf("qc.Verify(): %w", err) } - // Boundary: switch to the next epoch on Current's last CommitQC. - nextDuo, err := s.sealNextDuoIfLastRoad(ctx, ep, idx, utils.None[*types.AppQC]()) - if err != nil { - return err - } for inner, ctrl := range s.inner.Lock() { - if !inner.insertCommitQCAtTip(qc, nextDuo) { + if !inner.insertCommitQCAtTip(qc) { return nil } // latestCommitQC advances only after durable persist (or no-op persister). @@ -543,8 +515,9 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] return nil } -// PushAppQC requires a justifying CommitQC; tipcut insert uses the same -// sealing leashes as PushCommitQC. +// PushAppQC requires a justifying CommitQC. Epoch slide is async in +// runAdvanceEpoch (same as PushCommitQC). Prune before insert so latestAppQC is +// visible before the advance task observes the new tip. // // Same admit-then-verify as PushCommitQC. func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *types.CommitQC) error { @@ -580,12 +553,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) } - // Seal only when this tipcut closes Current (not Prev's last road). - // Pass this AppQC as incoming so a tipcut that first enters epoch N can close N. - nextDuo, err := s.sealNextDuoIfLastRoad(ctx, duo.Current, idx, utils.Some(appQC)) - if err != nil { - return err - } for inner, ctrl := range s.inner.Lock() { updated, err := inner.prune(appQC, commitQC) if err != nil { @@ -595,7 +562,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return nil } // prune advances pointers first; only then can pushBack land at idx. - inner.insertCommitQCAtTip(commitQC, nextDuo) + inner.insertCommitQCAtTip(commitQC) ctrl.Updated() } return nil @@ -897,7 +864,7 @@ func (s *State) produceLocalBlock(n types.BlockNumber, key types.SecretKey, payl // Run runs the background tasks of the state. // // Goroutines: this method spawns long-lived goroutines via scope.SpawnNamed -// (the persist loop and the FullCommitQC→data-state pusher). Inside +// (persist, epoch advance, and the FullCommitQC→data-state pusher). Inside // runPersist, scope.Parallel spawns short-lived goroutines for concurrent // per-lane block and commit-QC persistence. The persist package itself does // not spawn goroutines. @@ -906,6 +873,9 @@ func (s *State) Run(ctx context.Context) error { scope.SpawnNamed("persist", func() error { return s.runPersist(ctx, s.persisters) }) + scope.SpawnNamed("advanceEpoch", func() error { + return s.runAdvanceEpoch(ctx) + }) // Task inserting FullCommitQCs and local blocks to data state. // ErrPruned jumps n forward (AppQC/window prune during catch-up): skipped // roads need not be exported locally — peers can PushQC into data. @@ -943,6 +913,46 @@ func (s *State) Run(ctx context.Context) error { }) } +// runAdvanceEpoch is the sole post-construction writer of epochDuo. When +// commitQCs tip passes Current's last road, it waits for AppQC of Current and +// registry WaitForDuo, then advances. Push* must not slide the duo: tip may sit +// at Current.Next while Current is still N; N+1 CommitQCs park on waitForEpoch. +func (s *State) runAdvanceEpoch(ctx context.Context) error { + for { + duo := s.epochDuo.Load() + epochIdx := duo.Current.EpochIndex() + last := duo.Current.RoadRange().Next - 1 + + for inner, ctrl := range s.inner.Lock() { + if err := ctrl.WaitUntil(ctx, func() bool { + return inner.commitQCs.next > last + }); err != nil { + return err + } + } + + if err := s.waitForAppQC(ctx, epochIdx); err != nil { + return err + } + nextDuo, err := s.data.Registry().WaitForDuo(ctx, last+1) + if err != nil { + return err + } + + for inner, ctrl := range s.inner.Lock() { + live := inner.epochDuo.Load() + if live.Current.EpochIndex() != epochIdx { + break + } + if inner.commitQCs.next <= last { + break + } + inner.advanceEpoch(nextDuo) + ctrl.Updated() + } + } +} + // runPersist is the main loop for the persist goroutine. // Write order: // 1. Prune anchor (AppQC + CommitQC pair) — the crash-recovery watermark (sequential). diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index ecb85f3abe..c22ff39a90 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -40,6 +40,20 @@ func registerDuoAtEpoch(s *State, n types.EpochIndex) { } } +// advanceUntilCurrent runs runAdvanceEpoch until Current reaches want, then cancels. +func advanceUntilCurrent(t *testing.T, s *State, want types.EpochIndex) { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + errCh := make(chan error, 1) + go func() { errCh <- s.runAdvanceEpoch(ctx) }() + _, err := s.epochDuo.Wait(t.Context(), func(duo types.EpochDuo) bool { + return duo.Current.EpochIndex() >= want + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) +} + type byLane[T any] map[types.LaneID][]T func makeAppVotes(keys []types.SecretKey, proposal *types.AppProposal) []*types.Signed[*types.AppVote] { @@ -436,7 +450,7 @@ func TestWaitForAppQC(t *testing.T) { canceled, cancel := context.WithCancel(ctx) cancel() - require.ErrorIs(t, state.waitForAppQC(canceled, 0, utils.None[*types.AppQC]()), context.Canceled) + require.ErrorIs(t, state.waitForAppQC(canceled, 0), context.Canceled) lane := keys[0].Public() b, err := state.ProduceLocalBlock(state.NextBlock(lane), types.GenPayload(rng)) @@ -453,14 +467,14 @@ func TestWaitForAppQC(t *testing.T) { qc0.GlobalRange().Next-1, 0, types.GenAppHash(rng), 0))) done := make(chan error, 1) - go func() { done <- state.waitForAppQC(ctx, 0, utils.None[*types.AppQC]()) }() + go func() { done <- state.waitForAppQC(ctx, 0) }() require.NoError(t, state.PushAppQC(ctx, appQC, qc0)) require.NoError(t, <-done) - require.NoError(t, state.waitForAppQC(ctx, 0, utils.None[*types.AppQC]())) + require.NoError(t, state.waitForAppQC(ctx, 0)) canceled2, cancel2 := context.WithCancel(ctx) cancel2() - require.ErrorIs(t, state.waitForAppQC(canceled2, 1, utils.None[*types.AppQC]()), context.Canceled) + require.ErrorIs(t, state.waitForAppQC(canceled2, 1), context.Canceled) } // TestPushVote_WaitsForFutureEpochSigner: a voter not yet in Current parks until @@ -1262,7 +1276,7 @@ func TestPushCommitQCMidEpochNoExecLeash(t *testing.T) { } } -// TestPushCommitQCWaitsForEpochUnlock: sealing M without registry M+1 waits. +// TestPushCommitQCWaitsForEpochUnlock: advance waits on registry M+1 after tip+AppQC. func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) @@ -1303,19 +1317,21 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { } state.markCommitQCsPersisted(prevOnLast) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - require.ErrorIs(t, state.PushCommitQC(ctx, qcLast), context.Canceled) - - registry.EnsureEpoch(m + 1) require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) for inner := range state.inner.Lock() { require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) - require.Equal(t, m+1, inner.epochDuo.Load().Current.EpochIndex()) + require.Equal(t, m, inner.epochDuo.Load().Current.EpochIndex(), "Push does not advance duo") } + + canceled, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.runAdvanceEpoch(canceled), context.Canceled) + + registry.EnsureEpoch(m + 1) + advanceUntilCurrent(t, state, m+1) } -// TestPushAppQCWaitsForEpochUnlock: tipcut seal of M waits on registry M+1. +// TestPushAppQCWaitsForEpochUnlock: tipcut Push admits without M+1; advance waits. func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) @@ -1346,21 +1362,22 @@ func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { } state.markCommitQCsPersisted(prevOnLast) - ctx, cancel := context.WithCancel(t.Context()) - cancel() - // Incoming AppQC satisfies seal AppQC leash; still waits on missing M+1. - require.ErrorIs(t, state.PushAppQC(ctx, appQCLast, qcLast), context.Canceled) - - registry.EnsureEpoch(m + 1) require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) for inner := range state.inner.Lock() { require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) - require.Equal(t, m+1, inner.epochDuo.Load().Current.EpochIndex()) + require.Equal(t, m, inner.epochDuo.Load().Current.EpochIndex(), "Push does not advance duo") } + + canceled, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.runAdvanceEpoch(canceled), context.Canceled) + + registry.EnsureEpoch(m + 1) + advanceUntilCurrent(t, state, m+1) } // TestPushCommitQCBoundaryWaitsForAppQCInEpoch: last CommitQC of epoch M drops -// Prev (M-1); require AppQC in M so M-1 is pruned before the window slides. +// Prev (M-1); advance requires AppQC in M so M-1 is pruned before the window slides. func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) @@ -1399,9 +1416,15 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { } state.markCommitQCsPersisted(prevOnLast) - ctx, cancel := context.WithCancel(t.Context()) + require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + require.Equal(t, m, inner.epochDuo.Load().Current.EpochIndex()) + } + + canceled, cancel := context.WithCancel(t.Context()) cancel() - require.ErrorIs(t, state.PushCommitQC(ctx, qcLast), context.Canceled) + require.ErrorIs(t, state.runAdvanceEpoch(canceled), context.Canceled) // AppQC in epoch M unlocks the boundary. qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ @@ -1412,20 +1435,15 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { })), nil, utils.None[*types.AppQC]()) appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.EpochIndex()))) - for inner := range state.inner.Lock() { + for inner, ctrl := range state.inner.Lock() { inner.latestAppQC = utils.Some(appQCM) - inner.commitQCs.first = epoch.FirstRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) + ctrl.Updated() } - require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) - require.Equal(t, m+1, inner.epochDuo.Load().Current.EpochIndex()) - } + advanceUntilCurrent(t, state, m+1) } -// TestPushCommitQCEpoch0SealWaitsForAppQC: no epoch-0 exemption — seal waits +// TestPushCommitQCEpoch0SealWaitsForAppQC: no epoch-0 exemption — advance waits // for AppQC even though {∅,0}→{0,1} drops no Prev (anchor before Current>0). func TestPushCommitQCEpoch0SealWaitsForAppQC(t *testing.T) { rng := utils.TestRng() @@ -1452,27 +1470,28 @@ func TestPushCommitQCEpoch0SealWaitsForAppQC(t *testing.T) { } state.markCommitQCsPersisted(prevOnLast) - ctx, cancel := context.WithCancel(t.Context()) + require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(0)+1, inner.commitQCs.next) + require.Equal(t, types.EpochIndex(0), inner.epochDuo.Load().Current.EpochIndex()) + } + + canceled, cancel := context.WithCancel(t.Context()) cancel() - require.ErrorIs(t, state.PushCommitQC(ctx, qcLast), context.Canceled) + require.ErrorIs(t, state.runAdvanceEpoch(canceled), context.Canceled) appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( 0, 0, types.GenAppHash(rng), 0))) - for inner := range state.inner.Lock() { + for inner, ctrl := range state.inner.Lock() { inner.latestAppQC = utils.Some(appQC0) - inner.commitQCs.first = epoch.LastRoad(0) - inner.commitQCs.next = epoch.LastRoad(0) + ctrl.Updated() } - require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(0)+1, inner.commitQCs.next) - require.Equal(t, types.EpochIndex(1), inner.epochDuo.Load().Current.EpochIndex()) - } + advanceUntilCurrent(t, state, 1) } // TestPushAppQCBoundaryIncomingAppQC: tipcut closing M may carry the first -// AppQC in M; count it as incoming before prune so catch-up does not deadlock. +// AppQC in M; prune-before-insert makes it visible to runAdvanceEpoch. func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) @@ -1506,7 +1525,7 @@ func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.EpochIndex()))) for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCPrev) // stale; incoming is appQCLast + inner.latestAppQC = utils.Some(appQCPrev) // stale; PushAppQC prune installs appQCLast inner.commitQCs.first = epoch.LastRoad(m) inner.commitQCs.next = epoch.LastRoad(m) } @@ -1515,8 +1534,66 @@ func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) for inner := range state.inner.Lock() { require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) - require.Equal(t, m+1, inner.epochDuo.Load().Current.EpochIndex()) + require.Equal(t, m, inner.epochDuo.Load().Current.EpochIndex()) } + advanceUntilCurrent(t, state, m+1) +} + +// TestEpochAdvanceGapHandoff: LastRoad(N) Push leaves Current at N; FirstRoad(N+1) +// parks until runAdvanceEpoch slides the duo. +func TestEpochAdvanceGapHandoff(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + epNext := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m + 1))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + registerDuoAtEpoch(state, m) + + qcMid := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcMid.GlobalRange().First, qcMid.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + qcNext := makeCommitQC(epNext, keys, utils.Some(qcLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(m+1), qcNext.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQCM) + inner.commitQCs.first = epoch.FirstRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) + + require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) + require.Equal(t, m, state.epochDuo.Load().Current.EpochIndex()) + state.markCommitQCsPersisted(qcLast) // satisfy waitForCommitQC for FirstRoad(m+1) + + advCtx, advCancel := context.WithCancel(t.Context()) + advErr := make(chan error, 1) + go func() { advErr <- state.runAdvanceEpoch(advCtx) }() + + require.NoError(t, state.PushCommitQC(t.Context(), qcNext)) + require.Equal(t, m+1, state.epochDuo.Load().Current.EpochIndex()) + advCancel() + require.ErrorIs(t, <-advErr, context.Canceled) + }) } func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { From 0e9caf98dc21dc51b9039a242ef148db80c2f2ab Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 28 Jul 2026 22:07:39 -0700 Subject: [PATCH 70/85] Align tipcut AppQC with interlocking: optional attach, seal leashes on admit. Tipcuts may omit App; attach only if newer and in-window. Prune and exec leashes wait on admitting the last CommitQC of an epoch, not on propose. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 26 +- .../autobahn/types/proposal_test.go | 10 +- .../internal/autobahn/avail/state.go | 107 +++-- .../internal/autobahn/avail/state_test.go | 368 +++++++++--------- .../internal/autobahn/consensus/state.go | 14 +- 5 files changed, 257 insertions(+), 268 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index d9c00302e7..c97996c7ed 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -355,29 +355,23 @@ func buildProposal( } } app := ProposalOpt(appQC) - // If the new appProposal is not later than the previous one, then clear appQC. + // Attach AppQC only when newer than the previous tipcut's App; otherwise + // carry the CommitQC App forward with no attachment. if old := AppOpt(ProposalOpt(viewSpec.CommitQC)); NextOpt(app) <= NextOpt(old) { app = old appQC = utils.None[*AppQC]() } - // If the new appProposal is from the future (which may happen if this node is - // behind), drop it and fall back to the previous CommitQC's app so Verify - // never sees App < previous CommitQC. Well-formed AppQCs with road ≥ view - // have GlobalNumber ≥ NextGlobalBlock, so this also covers that case. + // Ahead of this tipcut: do not attach; keep prior CommitQC App (may be None). if a, ok := app.Get(); ok && a.GlobalNumber() >= viewSpec.NextGlobalBlock() { app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } - // AppQC must be Current or Current-1. Callers (consensus runPropose) must - // wait for an in-window App before constructing a tipcut when Current>0 — - // do not silently fall back to a stale CommitQC App. - if a, ok := app.Get(); ok { - appEp := a.EpochIndex() - if !viewSpec.Epoch().AcceptsAppEpoch(appEp) { - return nil, appQC, fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEp, viewSpec.Epoch().EpochIndex()) - } - } else if viewSpec.Epoch().EpochIndex() > 0 { - return nil, appQC, fmt.Errorf("App required for epoch %d tipcut (need Current or Current-1)", viewSpec.Epoch().EpochIndex()) + // Attached App must be Current or Current-1. Out-of-window candidates are + // not attached; tipcuts may omit App entirely (doc: may embed). Keep the + // CommitQC App when present so App is never lower than the prior tipcut. + if a, ok := app.Get(); ok && !viewSpec.Epoch().AcceptsAppEpoch(a.EpochIndex()) { + app = AppOpt(ProposalOpt(viewSpec.CommitQC)) + appQC = utils.None[*AppQC]() } // Normalize the creation timestamp. if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { @@ -520,7 +514,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { if m.appQC.IsPresent() { return errors.New("unnecessary appQC") } - // Carried-forward App (no attached AppQC) must still be in-window. + // Carried-forward App is optional; if present must stay in-window. if app, ok := m.proposal.Msg().App().Get(); ok { if !vs.Epoch().AcceptsAppEpoch(app.EpochIndex()) { return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 54fdc0c8db..ca81b059d8 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -724,9 +724,13 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { require.True(t, ok) require.Equal(t, EpochIndex(0), appPrev.EpochIndex()) - // AppQC outside {Current, Current-1} — rejected (no silent fallback). - _, err := NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2))) - require.Error(t, err) + // AppQC outside {Current, Current-1} — not attached; keep in-window CommitQC App. + fpOut := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2)))) + require.False(t, fpOut.appQC.IsPresent()) + appOut, ok := fpOut.Proposal().Msg().App().Get() + require.True(t, ok) + require.True(t, vs.Epoch().AcceptsAppEpoch(appOut.EpochIndex())) + require.NoError(t, fpOut.Verify(vs)) } func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 9529f87a24..b29209211b 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -299,13 +299,10 @@ func (s *State) waitRoadOrDropStale( // waitForAppQC blocks until latest AppQC is from epochIdx or later. // -// Called from runAdvanceEpoch when sealing epoch N (Current's last CommitQC -// already admitted). Epoch 0 is not special-cased: seal is {∅,0}→{0,1} (no Prev -// drop), but the leash still runs so leaving 0 always writes an AppQC anchor for -// newInner (Current>0 requires one). Do not reintroduce an epochIdx==0 skip: -// BlocksPerLane only caps local production; peers can PushCommitQC LastRoad(0) -// (then mid-epoch-1 QCs) with no local AppQC, which would otherwise restart -// without an anchor. +// Seal prune leash (interlocking doc): Availability admits the last CommitQC of +// epoch N only after an AppQC for N exists. Also used by runAdvanceEpoch as a +// no-op once admit already waited. Epoch 0 is not special-cased: leaving 0 +// still needs an AppQC anchor for restart (Current>0 requires one). func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex) error { for inner, ctrl := range s.inner.Lock() { ready := func() bool { @@ -325,66 +322,40 @@ func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex) err slog.Uint64("latest_app_qc_epoch", uint64(appQC.Proposal().EpochIndex())), ) } - logger.Warn("waiting for AppQC before advancing epoch", attrs...) + logger.Warn("waiting for AppQC before sealing 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() { - return inner.latestAppQC +// waitSealLeashes enforces the interlocking-doc seal conditions before admitting +// the last CommitQC of ep: AppQC for ep (unless incomingAppEpoch already +// satisfies) and registry WaitForDuo for the next road (execution leash). +func (s *State) waitSealLeashes( + ctx context.Context, + ep *types.Epoch, + idx types.RoadIndex, + incomingAppEpoch utils.Option[types.EpochIndex], +) error { + last := ep.RoadRange().Next - 1 + if idx != last { + return nil } - panic("unreachable") -} - -// tipcutAppQC returns LastAppQC when its epoch is usable for a tipcut whose -// Current is want (want or want-1). Otherwise None. -func (s *State) tipcutAppQC(want *types.Epoch) utils.Option[*types.AppQC] { - appQC, ok := s.LastAppQC().Get() - if !ok || !want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) { - return utils.None[*types.AppQC]() + if e, ok := incomingAppEpoch.Get(); !ok || e < ep.EpochIndex() { + if err := s.waitForAppQC(ctx, ep.EpochIndex()); err != nil { + return err + } } - return utils.Some(appQC) + if _, err := s.data.Registry().WaitForDuo(ctx, last+1); err != nil { + return fmt.Errorf("WaitForDuo(%d): %w", last+1, err) + } + return nil } -// WaitForTipcutAppQC returns an AppQC usable for a tipcut whose Current is -// want (want or want-1). For epoch 0, returns tipcutAppQC immediately -// (may be None). -// -// For want>0, proposing must not fall back to a CommitQC App older than want-1. -// If commitQC already carries an in-window App, returns tipcutAppQC without -// waiting (None is fine: tipcut keeps that CommitQC App). Otherwise blocks -// until latestAppQC is in-window. -func (s *State) WaitForTipcutAppQC( - ctx context.Context, - want *types.Epoch, - commitQC utils.Option[*types.CommitQC], -) (utils.Option[*types.AppQC], error) { - if want.EpochIndex() == 0 { - return s.tipcutAppQC(want), nil - } - if old, ok := types.AppOpt(types.ProposalOpt(commitQC)).Get(); ok && want.AcceptsAppEpoch(old.EpochIndex()) { - return s.tipcutAppQC(want), nil - } - for inner, ctrl := range s.inner.Lock() { - ready := func() bool { - appQC, ok := inner.latestAppQC.Get() - return ok && want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) - } - if !ready() { - logger.Warn("waiting for AppQC in EpochDuo before proposing", - slog.Uint64("want_epoch", uint64(want.EpochIndex()))) - if err := ctrl.WaitUntil(ctx, ready); err != nil { - return utils.None[*types.AppQC](), err - } - } - appQC, ok := inner.latestAppQC.Get() - if !ok || !want.AcceptsAppEpoch(appQC.Proposal().EpochIndex()) { - return utils.None[*types.AppQC](), fmt.Errorf("WaitForTipcutAppQC: AppQC not in duo after wait") - } - return utils.Some(appQC), nil +// LastAppQC returns the latest observed AppQC. +func (s *State) LastAppQC() utils.Option[*types.AppQC] { + for inner := range s.inner.Lock() { + return inner.latestAppQC } panic("unreachable") } @@ -426,6 +397,9 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi // Epoch slide is async in runAdvanceEpoch (tip may sit at Current.Next while // Current still N; N+1 CommitQCs park on waitForEpoch until the duo advances). // +// Seal (last road of Current): prune + execution leashes before admit +// (interlocking doc CommitQC admission). +// // Admit-then-verify is intentional backpressure for ahead-of-window QCs. func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() @@ -446,6 +420,9 @@ 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) } + if err := s.waitSealLeashes(ctx, ep, idx, utils.None[types.EpochIndex]()); err != nil { + return err + } for inner, ctrl := range s.inner.Lock() { if !inner.insertCommitQCAtTip(qc) { @@ -553,6 +530,13 @@ 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) } + // Seal CommitQC paired with this AppQC: incoming AppQC satisfies the prune + // leash; still wait on the execution leash when idx closes Current. + if duo.Current.RoadRange().Next-1 == idx && ep.EpochIndex() == duo.Current.EpochIndex() { + if err := s.waitSealLeashes(ctx, duo.Current, idx, utils.Some(appQC.Proposal().EpochIndex())); err != nil { + return err + } + } for inner, ctrl := range s.inner.Lock() { updated, err := inner.prune(appQC, commitQC) if err != nil { @@ -914,9 +898,10 @@ func (s *State) Run(ctx context.Context) error { } // runAdvanceEpoch is the sole post-construction writer of epochDuo. When -// commitQCs tip passes Current's last road, it waits for AppQC of Current and -// registry WaitForDuo, then advances. Push* must not slide the duo: tip may sit -// at Current.Next while Current is still N; N+1 CommitQCs park on waitForEpoch. +// commitQCs tip passes Current's last road, seal leashes have already been +// satisfied at PushCommitQC/PushAppQC admit; this waits for tip, re-checks +// leashes (no-op if already met), then advances. N+1 CommitQCs park on +// waitForEpoch until the duo slides. func (s *State) runAdvanceEpoch(ctx context.Context) error { for { duo := s.epochDuo.Load() diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index c22ff39a90..9812c2ef09 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -1276,218 +1276,232 @@ func TestPushCommitQCMidEpochNoExecLeash(t *testing.T) { } } -// TestPushCommitQCWaitsForEpochUnlock: advance waits on registry M+1 after tip+AppQC. +// TestPushCommitQCWaitsForEpochUnlock: seal admit waits on registry M+1 +// (execution leash) before inserting the last CommitQC of M. func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { - rng := utils.TestRng() - registry, keys, m := epoch.GenRegistryTip(rng, 4) - epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) - epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) - _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) - require.Error(t, err, "test setup: M+1 must be absent") - - ds := newTestDataState(&data.Config{Registry: registry}) - state, err := NewState(keys[0], ds, utils.None[string]()) - require.NoError(t, err) + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "test setup: M+1 must be absent") - registerDuoAtEpoch(state, m) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) - // Seal needs AppQC in M as well as registry M+1. - qcMid := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ - EpochIndex: m - 1, - Index: epoch.LastRoad(m - 1), - }))), - })), nil, utils.None[*types.AppQC]()) - appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qcMid.GlobalRange().First, qcMid.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + registerDuoAtEpoch(state, m) - prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ - EpochIndex: m, - Index: epoch.LastRoad(m) - 1, - }))), - }) - qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) - require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) + qcMid := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcMid.GlobalRange().First, qcMid.Index(), types.GenAppHash(rng), epM.EpochIndex()))) - for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCM) - inner.commitQCs.first = epoch.FirstRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) - } - state.markCommitQCsPersisted(prevOnLast) + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) - require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) - require.Equal(t, m, inner.epochDuo.Load().Current.EpochIndex(), "Push does not advance duo") - } + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQCM) + inner.commitQCs.first = epoch.LastRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) - canceled, cancel := context.WithCancel(t.Context()) - cancel() - require.ErrorIs(t, state.runAdvanceEpoch(canceled), context.Canceled) + errCh := make(chan error, 1) + go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() + synctest.Wait() // parked on WaitForDuo(M+1) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m), inner.commitQCs.next, "not admitted until exec leash") + } - registry.EnsureEpoch(m + 1) - advanceUntilCurrent(t, state, m+1) + registry.EnsureEpoch(m + 1) + synctest.Wait() + require.NoError(t, <-errCh) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + } + }) } -// TestPushAppQCWaitsForEpochUnlock: tipcut Push admits without M+1; advance waits. +// TestPushAppQCWaitsForEpochUnlock: seal PushAppQC admits after AppQC (prune +// leash satisfied by incoming) but waits on registry M+1 (execution leash). func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { - rng := utils.TestRng() - registry, keys, m := epoch.GenRegistryTip(rng, 4) - epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) - _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) - require.Error(t, err, "test setup: M+1 must be absent") - - ds := newTestDataState(&data.Config{Registry: registry}) - state, err := NewState(keys[0], ds, utils.None[string]()) - require.NoError(t, err) + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "test setup: M+1 must be absent") - registerDuoAtEpoch(state, m) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) - prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ - EpochIndex: m, - Index: epoch.LastRoad(m) - 1, - }))), - }) - qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) - appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.EpochIndex()))) - require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) + registerDuoAtEpoch(state, m) - for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.LastRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) - } - state.markCommitQCsPersisted(prevOnLast) + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) - require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) - require.Equal(t, m, inner.epochDuo.Load().Current.EpochIndex(), "Push does not advance duo") - } + for inner := range state.inner.Lock() { + inner.commitQCs.first = epoch.LastRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) - canceled, cancel := context.WithCancel(t.Context()) - cancel() - require.ErrorIs(t, state.runAdvanceEpoch(canceled), context.Canceled) + errCh := make(chan error, 1) + go func() { errCh <- state.PushAppQC(ctx, appQCLast, qcLast) }() + synctest.Wait() + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m), inner.commitQCs.next, "not admitted until exec leash") + } - registry.EnsureEpoch(m + 1) - advanceUntilCurrent(t, state, m+1) + registry.EnsureEpoch(m + 1) + synctest.Wait() + require.NoError(t, <-errCh) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + } + }) } -// TestPushCommitQCBoundaryWaitsForAppQCInEpoch: last CommitQC of epoch M drops -// Prev (M-1); advance requires AppQC in M so M-1 is pruned before the window slides. +// TestPushCommitQCBoundaryWaitsForAppQCInEpoch: last CommitQC of epoch M is not +// admitted until AppQC for M exists (prune leash on CommitQC admission). func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { - rng := utils.TestRng() - registry, keys, m := epoch.GenRegistryTip(rng, 4) - registry.EnsureEpoch(m + 1) // exec leash for sealing M - epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) - epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) - - ds := newTestDataState(&data.Config{Registry: registry}) - state, err := NewState(keys[0], ds, utils.None[string]()) - require.NoError(t, err) - - registerDuoAtEpoch(state, m) + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) // exec leash for sealing M + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) - qcPrev := makeCommitQC(epPrev, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ - EpochIndex: m - 1, - Index: epoch.LastRoad(m-1) - 1, - }))), - })), nil, utils.None[*types.AppQC]()) - appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) - prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ - EpochIndex: m, - Index: epoch.LastRoad(m) - 1, - }))), - }) - qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) - require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) + registerDuoAtEpoch(state, m) - for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCPrev) // only M-1 — not enough to close M - inner.commitQCs.first = epoch.LastRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) - } - state.markCommitQCsPersisted(prevOnLast) + qcPrev := makeCommitQC(epPrev, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + })), nil, utils.None[*types.AppQC]()) + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) - require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) - require.Equal(t, m, inner.epochDuo.Load().Current.EpochIndex()) - } + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) - canceled, cancel := context.WithCancel(t.Context()) - cancel() - require.ErrorIs(t, state.runAdvanceEpoch(canceled), context.Canceled) + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQCPrev) // only M-1 — not enough to close M + inner.commitQCs.first = epoch.LastRoad(m) + inner.commitQCs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) - // AppQC in epoch M unlocks the boundary. - qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ - EpochIndex: m - 1, - Index: epoch.LastRoad(m - 1), - }))), - })), nil, utils.None[*types.AppQC]()) - appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.EpochIndex()))) - for inner, ctrl := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCM) - ctrl.Updated() - } + errCh := make(chan error, 1) + go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() + synctest.Wait() + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m), inner.commitQCs.next, "not admitted without AppQC in M") + } - advanceUntilCurrent(t, state, m+1) + qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + for inner, ctrl := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQCM) + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + } + }) } -// TestPushCommitQCEpoch0SealWaitsForAppQC: no epoch-0 exemption — advance waits -// for AppQC even though {∅,0}→{0,1} drops no Prev (anchor before Current>0). +// TestPushCommitQCEpoch0SealWaitsForAppQC: no epoch-0 exemption — admitting +// LastRoad(0) waits for AppQC before insert. func TestPushCommitQCEpoch0SealWaitsForAppQC(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistryAt(rng, 4, 0) - registry.EnsureEpoch(1) // exec leash for sealing 0 - ep0 := utils.OrPanic1(registry.EpochAt(0)) - - ds := newTestDataState(&data.Config{Registry: registry}) - state, err := NewState(keys[0], ds, utils.None[string]()) - require.NoError(t, err) - - prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ - EpochIndex: 0, - Index: epoch.LastRoad(0) - 1, - }))), - }) - qcLast := makeCommitQC(ep0, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) - require.Equal(t, epoch.LastRoad(0), qcLast.Proposal().Index()) + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + registry.EnsureEpoch(1) // exec leash for sealing 0 + ep0 := utils.OrPanic1(registry.EpochAt(0)) - for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.LastRoad(0) - inner.commitQCs.next = epoch.LastRoad(0) - } - state.markCommitQCsPersisted(prevOnLast) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) - require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(0)+1, inner.commitQCs.next) - require.Equal(t, types.EpochIndex(0), inner.epochDuo.Load().Current.EpochIndex()) - } + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0) - 1, + }))), + }) + qcLast := makeCommitQC(ep0, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(0), qcLast.Proposal().Index()) - canceled, cancel := context.WithCancel(t.Context()) - cancel() - require.ErrorIs(t, state.runAdvanceEpoch(canceled), context.Canceled) + for inner := range state.inner.Lock() { + inner.commitQCs.first = epoch.LastRoad(0) + inner.commitQCs.next = epoch.LastRoad(0) + } + state.markCommitQCsPersisted(prevOnLast) - appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( - 0, 0, types.GenAppHash(rng), 0))) - for inner, ctrl := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQC0) - ctrl.Updated() - } + errCh := make(chan error, 1) + go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() + synctest.Wait() + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(0), inner.commitQCs.next) + } - advanceUntilCurrent(t, state, 1) + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + 0, 0, types.GenAppHash(rng), 0))) + for inner, ctrl := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC0) + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(0)+1, inner.commitQCs.next) + } + }) } // TestPushAppQCBoundaryIncomingAppQC: tipcut closing M may carry the first diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index a75fb7e562..f17bc7ce06 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -286,22 +286,14 @@ func (s *State) runPropose(ctx context.Context) error { if !s.shouldPropose(vs) || ep.EpochIndex() != vs.Epoch().EpochIndex() { return nil } - // For Current>0, do not propose until App is within {Current, Current-1} - // (CommitQC App or live AppQC). Omitting an out-of-window AppQC and - // falling back to a stale CommitQC App is not allowed. - appQC, err := s.avail.WaitForTipcutAppQC(ctx, vs.Epoch(), vs.CommitQC) - if err != nil { - return fmt.Errorf("s.avail.WaitForTipcutAppQC(): %w", err) - } - if !s.shouldPropose(vs) { - return nil - } + // AppQC is optional on tipcuts: attach only when LastAppQC is newer and + // in-window (buildProposal). Seal AppQC leash is on CommitQC admission. fullProposal, err := types.NewProposal( s.cfg.Key, vs, time.Now(), laneQCsMap, - appQC, + s.avail.LastAppQC(), ) if err != nil { return fmt.Errorf("types.NewProposal(): %w", err) From 30f14d174042d2940ba4de03fda037ae20ae3eeb Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 29 Jul 2026 07:26:09 -0700 Subject: [PATCH 71/85] Fix flaky data client-server test isolation Exercise block sync without concurrently running consensus against a synthetic QC chain, which could fork lane data and hang replication. Co-authored-by: Cursor --- sei-tendermint/internal/p2p/giga/data_test.go | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index c9b9d97e2c..2c99690451 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -98,30 +98,63 @@ func TestDataClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 2) - env := newTestEnv(registry) - server := env.AddNode(keys[0]) - client := env.AddNode(keys[1]) - firstBlock := server.data.Registry().FirstBlock() + serverData, err := data.NewState(&data.Config{Registry: registry}, memblock.NewBlockDB()) + if err != nil { + t.Fatal(err) + } + clientData, err := data.NewState(&data.Config{Registry: registry}, memblock.NewBlockDB()) + if err != nil { + t.Fatal(err) + } + serverService := NewBlockSyncService(serverData) + clientService := NewBlockSyncService(clientData) + firstBlock := serverData.Registry().FirstBlock() if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - s.SpawnBg(func() error { return env.Run(ctx) }) + serverConn, clientConn := conn.NewTestConn() + serverRPC := rpc.NewServer[API]() + clientRPC := rpc.NewClient[API]() + s.SpawnBgNamed("server data", func() error { + return utils.IgnoreAfterCancel(ctx, serverData.Run(ctx)) + }) + s.SpawnBgNamed("client data", func() error { + return utils.IgnoreAfterCancel(ctx, clientData.Run(ctx)) + }) + s.SpawnBgNamed("server service", func() error { + return utils.IgnoreAfterCancel(ctx, serverService.Run(ctx)) + }) + s.SpawnBgNamed("client service", func() error { + return utils.IgnoreAfterCancel(ctx, clientService.Run(ctx)) + }) + s.SpawnBgNamed("mux server", func() error { + return utils.IgnoreAfterCancel(ctx, serverRPC.Run(ctx, serverConn)) + }) + s.SpawnBgNamed("mux client", func() error { + return utils.IgnoreAfterCancel(ctx, clientRPC.Run(ctx, clientConn)) + }) + s.SpawnBgNamed("block sync server", func() error { + return utils.IgnoreAfterCancel(ctx, serverService.RunBlockSyncServer(ctx, serverRPC)) + }) + s.SpawnBgNamed("block sync client", func() error { + return utils.IgnoreAfterCancel(ctx, clientService.RunBlockSyncClient(ctx, clientRPC)) + }) t.Logf("push data") prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := data.TestCommitQC(rng, server.data.Registry().EpochAtTip(prev), keys, prev) - if err := server.data.PushQC(ctx, qc, blocks); err != nil { + qc, blocks := data.TestCommitQC(rng, serverData.Registry().EpochAtTip(prev), keys, prev) + if err := serverData.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("serverState.PushQC(): %w", err) } prev = utils.Some(qc.QC()) } t.Logf("wait for replication") - for n := firstBlock; n < server.data.NextBlock(); n++ { - want, err := server.data.GlobalBlock(ctx, n) + for n := firstBlock; n < serverData.NextBlock(); n++ { + want, err := serverData.GlobalBlock(ctx, n) if err != nil { return fmt.Errorf("serverState.FinalBlock(): %w", err) } - got, err := client.data.GlobalBlock(ctx, n) + got, err := clientData.GlobalBlock(ctx, n) if err != nil { return fmt.Errorf("clientState.FinalBlock(): %w", err) } @@ -129,11 +162,11 @@ func TestDataClientServer(t *testing.T) { return err } - wantQC, err := server.data.QC(ctx, n) + wantQC, err := serverData.QC(ctx, n) if err != nil { return fmt.Errorf("serverState.CommitQC(): %w", err) } - gotQC, err := client.data.QC(ctx, n) + gotQC, err := clientData.QC(ctx, n) if err != nil { return fmt.Errorf("clientState.CommitQC(): %w", err) } From 6bec193a412d43e73fca7c3c6218a259d2fa3590 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 29 Jul 2026 09:16:35 -0700 Subject: [PATCH 72/85] Drop lane votes invalidated by epoch advance Recheck both signer and lane against the live committee after capacity waits so votes verified under the prior epoch are not credited after rotation. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 3 +- .../internal/autobahn/avail/state_test.go | 43 +++++++++++++++++++ .../internal/autobahn/epoch/testonly.go | 2 + 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index b29209211b..fb0322141b 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -685,7 +685,8 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote // WaitUntil may release the lock; re-check membership under live Current. live := inner.epochDuo.Load() if live.Current.EpochIndex() != verifiedEpoch && - live.Current.Committee().Weight(vote.Key()) == 0 { + (live.Current.Committee().Weight(vote.Key()) == 0 || + !live.Current.Committee().HasLane(h.Lane())) { return nil } if h.BlockNumber() < q.first { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 9812c2ef09..9a542e1884 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -581,6 +581,49 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { }) } +// TestPushVote_DropsLaneAfterEpochAdvance: after verify, Current advances to a +// committee that retains the signer but not the voted lane. +func TestPushVote_DropsLaneAfterEpochAdvance(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + ep0 := state.epochDuo.Load().Current + lane := keys[1].Public() + n := types.BlockNumber(BlocksPerLane) + header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(keys[0], types.NewLaneVote(header)) + + weights := map[types.PublicKey]uint64{ + keys[0].Public(): 1000, + keys[2].Public(): 1000, + } + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, + utils.OrPanic1(types.NewCommittee(weights))) + duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() + + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(duo1) + inner.lanes[lane].persistedBlockStart = 1 + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + require.Equal(t, types.BlockNumber(0), inner.lanes[lane].votes.next, + "dropped vote must not extend the queue") + } + }) +} + // TestPushVote_CountsSignerAfterEpochAdvance: same WaitUntil race window, but the // new Current still includes the signer — count with live committee weights. func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 7b665acc1a..4ed2bdb3ac 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -62,6 +62,8 @@ func GenRegistryAt(rng utils.Rng, size int, startEpoch types.EpochIndex) (*Regis weights[sk.Public()] = 1000 + uint64(rng.Intn(1000)) //nolint:gosec } committee := utils.OrPanic1(types.NewCommittee(weights)) + // Production genesis starts at global block 0; randomizing this would detach + // empty-store CommitQC road 0 from the registry's genesis epoch. const firstBlock types.GlobalBlockNumber = 0 return makeRegistryAt(committee, firstBlock, startEpoch), sks } From bdb2fd3f08de74e0b597661311884c85588c6cea Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 29 Jul 2026 10:09:47 -0700 Subject: [PATCH 73/85] Clarify AppQC filtering comments Co-authored-by: Cursor --- sei-db/ledger_db/block/blocksim/block_generator.go | 3 ++- sei-tendermint/internal/autobahn/data/testonly.go | 3 ++- 2 files changed, 4 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 ba6b3c11d3..bdf15b2230 100644 --- a/sei-db/ledger_db/block/blocksim/block_generator.go +++ b/sei-db/ledger_db/block/blocksim/block_generator.go @@ -179,7 +179,8 @@ func (g *BlockGenerator) buildFullCommitQC() (*types.FullCommitQC, []*types.Bloc } leader := committee.Leader(viewSpec.View()) // AppQC certifies the prior tipcut (road == prev.Index), not the tipcut - // being built (View.Index). buildProposal clears road >= view. + // being built. buildProposal drops AppQCs whose block is not finalized; + // Verify additionally requires road < view. var appQC utils.Option[*types.AppQC] if cqc, ok := prev.Get(); ok { p := types.NewAppProposal( diff --git a/sei-tendermint/internal/autobahn/data/testonly.go b/sei-tendermint/internal/autobahn/data/testonly.go index 1ce25de357..bd082b02ed 100644 --- a/sei-tendermint/internal/autobahn/data/testonly.go +++ b/sei-tendermint/internal/autobahn/data/testonly.go @@ -61,7 +61,8 @@ func TestCommitQC( var appQC utils.Option[*types.AppQC] if cqc, ok := prev.Get(); ok { // AppQC certifies the prior tipcut (road == prev.Index), not the tipcut - // being built (View.Index). buildProposal clears road >= view. + // being built. buildProposal drops AppQCs whose block is not finalized; + // Verify additionally requires road < view. p := types.NewAppProposal( cqc.GlobalRange().Next-1, cqc.Proposal().Index(), From 18f657f807206f21e1551b15fc2a03c43bac0b5b Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 29 Jul 2026 11:09:40 -0700 Subject: [PATCH 74/85] Harden epoch restart and persistence snapshots Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 26 +++++++++---------- .../internal/autobahn/consensus/state.go | 4 +-- .../internal/autobahn/epoch/registry.go | 12 ++++++--- .../internal/autobahn/epoch/registry_test.go | 9 ++++--- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index fb0322141b..1c59f9d6b4 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -1016,20 +1016,8 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { for lane := range blocksByLane { 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) - } - for lane := range ep.Committee().Lanes().All() { - batchLanes[lane] = struct{}{} - } + for _, lane := range batch.pruneLanes { + batchLanes[lane] = struct{}{} } for lane := range batchLanes { proposals := blocksByLane[lane] @@ -1049,6 +1037,7 @@ type persistBatch struct { blocks []*types.Signed[*types.LaneProposal] commitQCs []*types.CommitQC pruneAnchor utils.Option[*PruneAnchor] + pruneLanes []types.LaneID } // advancePersistedBlockStart updates the per-lane block admission watermark @@ -1132,6 +1121,15 @@ func (s *State) collectPersistBatch(ctx context.Context, lastPersistedAppQCNext AppQC: appQC, CommitQC: qc, }) + // Capture under the same lock as the anchor so an epoch slide + // cannot move its committee out of the live duo before I/O. + ep, err := inner.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) + if err != nil { + return b, fmt.Errorf("EpochForRoad(%d): %w", qc.Proposal().Index(), err) + } + for lane := range ep.Committee().Lanes().All() { + b.pruneLanes = append(b.pruneLanes, lane) + } } } } diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index f17bc7ce06..d207d7cd75 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -385,8 +385,8 @@ func (s *State) Run(ctx context.Context) error { }) scope.SpawnNamed("pushCommitQC", func() error { // 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. + // verified at admit. Tip watch may coalesce; pushCommitQC re-verifies + // against the QC's epoch and aligns the duo without replaying roads. 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) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 359e45ea13..adc38963f4 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -89,10 +89,11 @@ func NewRegistry( // SetupInitialDuo seeds placeholder epochs on restart. Call only from // data.NewState. Idempotent for existing entries. // -// commitQCs is the half-open retained CommitQC range [First, Next). Seeds every -// epoch covering [First, Next), EnsureDuoAt(Next), then placeholder -// windowLast+1/+2 (see below). None = empty store → EnsureDuoAt(FirstRoad(1)) -// so {0,1}. Empty range (First >= Next) returns an error. +// commitQCs is the half-open retained CommitQC range [First, Next). Seeds the +// duo at First, every epoch covering [First, Next), the duo at Next, then +// placeholder windowLast+1/+2 (see below). None = empty store → {0,1}; +// execution seeds epoch 2 before sealing epoch 0. Empty range (First >= Next) +// returns an error. func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) error { if span, ok := commitQCs.Get(); ok { if span.First >= span.Next { @@ -101,6 +102,9 @@ func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) erro windowFirst := IndexForRoad(span.First) windowLast := IndexForRoad(span.Next - 1) + // Avail WAL and BlockDB prune independently. Avail may restart at the + // retained span's first epoch and still need its Prev. + r.EnsureDuoAt(span.First) for s := range r.state.Lock() { for idx := windowFirst; idx <= windowLast; 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 742836930e..32872f5be5 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -146,20 +146,21 @@ func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { func TestSetupInitialDuo_CommitSpanFromFirst(t *testing.T) { r, _ := makeRegistry(t) - // Span mid-2..mid-5 → seed epochs 2..5, then placeholder through windowLast+2 → 7. + // Span mid-2..mid-5 → seed the first duo {1,2}, epochs through 5, + // then placeholders through windowLast+2 → 7. if err := r.SetupInitialDuo(utils.Some(types.RoadRange{ First: midRoad(2), Next: midRoad(5) + 1, })); err != nil { t.Fatal(err) } - for _, idx := range []types.EpochIndex{2, 3, 4, 5, 6, 7} { + for _, idx := range []types.EpochIndex{1, 2, 3, 4, 5, 6, 7} { if _, err := r.EpochAt(FirstRoad(idx)); err != nil { t.Fatalf("EpochAt(epoch %d) after commit span seeding: %v", idx, err) } } - if _, err := r.EpochAt(FirstRoad(1)); err == nil { - t.Fatal("EpochAt(epoch 1) should not be present when span.First is in epoch 2") + if _, err := r.DuoAt(midRoad(2)); err != nil { + t.Fatalf("DuoAt(span.First) after commit span seeding: %v", err) } if _, err := r.EpochAt(FirstRoad(8)); err == nil { t.Fatal("EpochAt(epoch 8) should not be present past placeholder windowLast+2") From 8f3cf44b9854e3713263011f8764adbe174431ce Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 29 Jul 2026 13:31:27 -0700 Subject: [PATCH 75/85] Tighten Autobahn test and error diagnostics Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo_test.go | 13 +++++++------ sei-tendermint/internal/autobahn/avail/state.go | 2 +- sei-tendermint/internal/autobahn/data/state.go | 4 +++- sei-tendermint/internal/p2p/giga/avail.go | 4 ++-- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index feba6aaa63..96e2ecbf56 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -8,9 +8,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -func testDuoEpochs(t *testing.T) (prev, current *types.Epoch) { +func testDuoEpochs(t *testing.T, rng utils.Rng) (prev, current *types.Epoch) { t.Helper() - rng := utils.TestRng() weights := map[types.PublicKey]uint64{} for range 3 { weights[types.GenSecretKey(rng).Public()] = 1 @@ -22,8 +21,8 @@ func testDuoEpochs(t *testing.T) (prev, current *types.Epoch) { } func TestNewEpochDuo_PanicsOnNonContiguousIndex(t *testing.T) { - prev, _ := testDuoEpochs(t) rng := utils.TestRng() + prev, _ := testDuoEpochs(t, rng) weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} committee := utils.OrPanic1(types.NewCommittee(weights)) // Roads abut, but index jumps 0 → 2. @@ -51,7 +50,8 @@ func TestNewEpochDuo_PanicsOnNonContiguousRoads(t *testing.T) { } func TestNewEpochDuo_PanicsOnPrevCurrentMismatch(t *testing.T) { - prev, current := testDuoEpochs(t) + rng := utils.TestRng() + prev, current := testDuoEpochs(t, rng) t.Run("prev_absent_with_current_gt_0", func(t *testing.T) { defer func() { if recover() == nil { @@ -71,8 +71,8 @@ func TestNewEpochDuo_PanicsOnPrevCurrentMismatch(t *testing.T) { } func TestEpochForRoad(t *testing.T) { - prev, current := testDuoEpochs(t) rng := utils.TestRng() + prev, current := testDuoEpochs(t, rng) weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} committee := utils.OrPanic1(types.NewCommittee(weights)) // Prev absent only for epoch 0. @@ -113,7 +113,8 @@ func TestEpochForRoad(t *testing.T) { } func TestEpochDuo_RoadStatus(t *testing.T) { - prev, current := testDuoEpochs(t) + rng := utils.TestRng() + prev, current := testDuoEpochs(t, rng) withPrev := types.NewEpochDuo(current, utils.Some(prev)) ep0Only := types.NewEpochDuo(prev, utils.None[*types.Epoch]()) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 1c59f9d6b4..4ed3fea255 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -599,7 +599,7 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos return fmt.Errorf("block.Verify(): %w", err) } if err := p.VerifySig(c); err != nil { - return fmt.Errorf("block.Verify(): %w", err) + return fmt.Errorf("block.VerifySig(): %w", err) } for inner, ctrl := range s.inner.Lock() { ls, ok := inner.lanes[h.Lane()] diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 3ac6cd3552..0a29d97d61 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -446,7 +446,9 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma // waits for the boundary slide (rather than ErrRoadAfterWindow). Epoch via // epochDuo only (not Registry). Before-window on a still-needed QC hard-fails; // if needQC is false the QC is already applied and a before-window miss is a -// no-op (stale peer redelivery after the duo slid — do not soft-admit via Registry). +// no-op. The 4k-block ingest leash is far shorter than the two-epoch window, so +// blocks accompanying such a stale redelivery are already outside the backfill +// horizon; do not soft-admit them via Registry. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { gr := qc.QC().GlobalRange() needQC, err := func() (bool, error) { diff --git a/sei-tendermint/internal/p2p/giga/avail.go b/sei-tendermint/internal/p2p/giga/avail.go index e4f77ffafb..40e8c7043f 100644 --- a/sei-tendermint/internal/p2p/giga/avail.go +++ b/sei-tendermint/internal/p2p/giga/avail.go @@ -197,7 +197,7 @@ func (x *Service) clientStreamCommitQCs(ctx context.Context, c rpc.Client[API]) return fmt.Errorf("types.CommitQCConv.Decode(): %w", err) } if err := x.validatorState().Avail().PushCommitQC(ctx, qc); err != nil { - return fmt.Errorf("s.PushFirstCommitQC(): %w", err) + return fmt.Errorf("s.PushCommitQC(): %w", err) } } } @@ -245,7 +245,7 @@ func (x *Service) clientStreamAppQCs(ctx context.Context, c rpc.Client[API]) err return fmt.Errorf("StreamAppQCsRespConv.Decode(): %w", err) } if err := x.validatorState().Avail().PushAppQC(ctx, msg.AppQC, msg.CommitQC); err != nil { - return fmt.Errorf("s.PushFirstCommitQC(): %w", err) + return fmt.Errorf("s.PushAppQC(): %w", err) } } } From 0c5dcb1706c0af1124d07d72f8e52ad098135f77 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 29 Jul 2026 13:40:40 -0700 Subject: [PATCH 76/85] Adapt epoch recovery to unified BlockDB iterator Co-authored-by: Cursor --- .../internal/autobahn/consensus/state_test.go | 3 +- .../internal/autobahn/data/state.go | 56 +++++++++---------- 2 files changed, 28 insertions(+), 31 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 8ab21277df..db83f81404 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -388,8 +388,7 @@ func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { dataRoad := epoch.FirstRoad(n) dataQC := fullCommitQCAtRoad(ep1, keys, dataRoad) db1 := newTestBlockDB(t, dataDir) - grQC := dataQC.QC().GlobalRange() - require.NoError(t, db1.WriteQC(grQC.First, grQC.Next, dataQC)) + require.NoError(t, db1.WriteQC(dataQC)) require.NoError(t, db1.Flush()) require.NoError(t, db1.Close()) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 0a29d97d61..68b0d3ed29 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -233,40 +233,38 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { // commitQCSpan returns the half-open [First, Next) of retained CommitQC roads, // or None when the store holds no QCs. Used to seed SetupInitialDuo. func commitQCSpan(blockDB types.BlockDB) (utils.Option[types.RoadRange], error) { - first, ok, err := boundaryQCRoad(blockDB, false) - if err != nil || !ok { - return utils.None[types.RoadRange](), err - } - last, ok, err := boundaryQCRoad(blockDB, true) - if err != nil { - return utils.None[types.RoadRange](), err - } - if !ok { - return utils.None[types.RoadRange](), fmt.Errorf("CommitQC span: first present but last missing") - } - if last < first { - return utils.None[types.RoadRange](), fmt.Errorf("CommitQC span: last road %d < first %d", last, first) - } - return utils.Some(types.RoadRange{First: first, Next: last + 1}), nil -} - -// boundaryQCRoad returns the proposal road of the first (reverse=false) or last -// (reverse=true) retained CommitQC, or ok=false when the store holds no QCs. -func boundaryQCRoad(blockDB types.BlockDB, reverse bool) (types.RoadIndex, bool, error) { - it, err := blockDB.QCs(reverse) + it, err := blockDB.Iterator(0) if err != nil { - return 0, false, fmt.Errorf("open QC iterator: %w", err) + return utils.None[types.RoadRange](), fmt.Errorf("open BlockDB iterator: %w", err) } defer func() { _ = it.Close() }() - ok, err := it.Next() - if err != nil || !ok { - return 0, false, err + + var first, last types.RoadIndex + var previous *types.FullCommitQC + present := false + for { + pos, ok, err := it.Next() + if err != nil { + return utils.None[types.RoadRange](), fmt.Errorf("advance BlockDB iterator: %w", err) + } + if !ok { + break + } + if pos.QC == previous { + continue + } + previous = pos.QC + idx := pos.QC.QC().Proposal().Index() + if !present { + first = idx + present = true + } + last = idx } - qc, err := it.QC() - if err != nil { - return 0, false, err + if !present { + return utils.None[types.RoadRange](), nil } - return qc.QC().Proposal().Index(), true, nil + return utils.Some(types.RoadRange{First: first, Next: last + 1}), nil } // loadFromBlockDB replays QCs and blocks from blockDB into s.inner. From 2f75f9c6ba92f132e779c3a866d2756a4f604020 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 30 Jul 2026 11:14:55 -0700 Subject: [PATCH 77/85] Harden AppProposal and availability epoch handling Validate carried app state exactly and keep vote/pruning state coherent across epoch transitions. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch.go | 11 ---- sei-tendermint/autobahn/types/epoch_test.go | 24 ------- sei-tendermint/autobahn/types/proposal.go | 54 ++++++++-------- .../autobahn/types/proposal_test.go | 42 ++++++++++--- .../internal/autobahn/avail/block_votes.go | 32 ++++------ .../autobahn/avail/block_votes_test.go | 26 ++++---- .../internal/autobahn/avail/inner.go | 39 +++++------- .../internal/autobahn/avail/inner_test.go | 8 +-- .../internal/autobahn/avail/state.go | 63 ++++++------------- .../internal/autobahn/avail/state_test.go | 2 +- 10 files changed, 129 insertions(+), 172 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index ca7a51bcee..2b4263571b 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -46,14 +46,3 @@ func NewEpoch(index EpochIndex, roads RoadRange, committee *Committee) *Epoch { func (e *Epoch) EpochIndex() EpochIndex { return e.epochIndex } func (e *Epoch) RoadRange() RoadRange { return e.roads } func (e *Epoch) Committee() *Committee { return e.committee } - -// AcceptsAppEpoch reports whether appEp is usable for a tipcut whose Current is -// e: e itself, or e-1 (Prev lag). Epoch 0 accepts only epoch 0. -// Use e-1 (not appEp+1) so uint64 wrap cannot admit MaxUint64 when e==0. -func (e *Epoch) AcceptsAppEpoch(appEp EpochIndex) bool { - cur := e.epochIndex - if appEp == cur { - return true - } - return cur > 0 && appEp == cur-1 -} diff --git a/sei-tendermint/autobahn/types/epoch_test.go b/sei-tendermint/autobahn/types/epoch_test.go index ef3b51b535..528aff6cbc 100644 --- a/sei-tendermint/autobahn/types/epoch_test.go +++ b/sei-tendermint/autobahn/types/epoch_test.go @@ -3,7 +3,6 @@ package types import ( "testing" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) @@ -22,26 +21,3 @@ func TestRoadRange_IsLastRoad(t *testing.T) { require.True(t, r.IsLastRoad(12)) require.False(t, r.IsLastRoad(13)) } - -func TestEpoch_AcceptsAppEpoch(t *testing.T) { - rng := utils.TestRng() - weights := map[PublicKey]uint64{GenSecretKey(rng).Public(): 1} - committee := utils.OrPanic1(NewCommittee(weights)) - cases := []struct { - cur, app EpochIndex - ok bool - }{ - {0, 0, true}, - {0, 1, false}, - {0, ^EpochIndex(0), false}, // MaxUint64 must not count as cur-1 - {1, 1, true}, - {1, 0, true}, - {1, 2, false}, - {2, 1, true}, - {2, 0, false}, - } - for _, tc := range cases { - ep := NewEpoch(tc.cur, RoadRange{First: 0, Next: 1}, committee) - require.Equal(t, tc.ok, ep.AcceptsAppEpoch(tc.app), "Epoch(%d).AcceptsAppEpoch(%d)", tc.cur, tc.app) - } -} diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index c97996c7ed..ce6c1fa3f4 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -215,6 +215,13 @@ func (m *Proposal) App() utils.Option[*AppProposal] { return m.app } // EpochIndex returns the epoch index encoded in the proposal. func (m *Proposal) EpochIndex() EpochIndex { return m.view.EpochIndex } +// acceptsAppEpoch reports whether appEp is usable by this proposal: the +// proposal's own epoch, or the immediately preceding epoch. +func (m *Proposal) acceptsAppEpoch(appEp EpochIndex) bool { + cur := m.EpochIndex() + return appEp == cur || cur > 0 && appEp == cur-1 +} + // GlobalRange returns the proposed global block range. func (m *Proposal) GlobalRange() GlobalRange { return m.globalRange @@ -366,18 +373,17 @@ func buildProposal( app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } - // Attached App must be Current or Current-1. Out-of-window candidates are - // not attached; tipcuts may omit App entirely (doc: may embed). Keep the - // CommitQC App when present so App is never lower than the prior tipcut. - if a, ok := app.Get(); ok && !viewSpec.Epoch().AcceptsAppEpoch(a.EpochIndex()) { - app = AppOpt(ProposalOpt(viewSpec.CommitQC)) - appQC = utils.None[*AppQC]() - } // Normalize the creation timestamp. if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { timestamp = wantMin } proposal := newProposal(viewSpec.View(), timestamp, laneRanges, app, viewSpec.NextGlobalBlock()) + if a, ok := proposal.App().Get(); ok && !proposal.acceptsAppEpoch(a.EpochIndex()) { + return nil, appQC, fmt.Errorf( + "app epoch_index %d incompatible with proposal epoch %d", + a.EpochIndex(), proposal.EpochIndex(), + ) + } if proposal.GlobalRange().Len() == 0 { return nil, appQC, errors.New("empty tipcut: need at least one LaneQC") } @@ -507,28 +513,32 @@ func (m *FullProposal) Verify(vs ViewSpec) error { }) } } + proposalApp := proposal.App() + if app, ok := proposalApp.Get(); ok && !proposal.acceptsAppEpoch(app.EpochIndex()) { + return fmt.Errorf( + "app epoch_index %d incompatible with proposal epoch %d", + app.EpochIndex(), proposal.EpochIndex(), + ) + } + // Verify the appQC. - if got, wantMin := NextOpt(m.proposal.Msg().App()), NextOpt(AppOpt(ProposalOpt(vs.CommitQC))); got < wantMin { + previousApp := AppOpt(ProposalOpt(vs.CommitQC)) + if got, wantMin := NextOpt(proposalApp), NextOpt(previousApp); got < wantMin { return errors.New("AppProposal lower than in previous CommitQC") } else if got == wantMin { if m.appQC.IsPresent() { return errors.New("unnecessary appQC") } - // Carried-forward App is optional; if present must stay in-window. - if app, ok := m.proposal.Msg().App().Get(); ok { - if !vs.Epoch().AcceptsAppEpoch(app.EpochIndex()) { - return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", - app.EpochIndex(), vs.Epoch().EpochIndex()) + if app, ok := proposalApp.Get(); ok { + previous := previousApp.OrPanic("previous AppProposal is present when NextOpt matches") + if NewHashed(NewAppVote(app)).hash != NewHashed(NewAppVote(previous)).hash { + return errors.New("AppProposal differs from previous CommitQC") } } } else { - app, _ := m.proposal.Msg().App().Get() + app := proposalApp.OrPanic("AppProposal is present when NextOpt increased") appEpoch := app.EpochIndex() cur := vs.Epoch() - // Allow Current or Current-1 (Prev lag). Reject anything else. - if !cur.AcceptsAppEpoch(appEpoch) { - return fmt.Errorf("app epoch_index %d not Current (%d) or Current-1", appEpoch, cur.EpochIndex()) - } appQC, ok := m.appQC.Get() if !ok { return errors.New("appQC missing") @@ -539,13 +549,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { s.Spawn(func() error { ep := vs.Epochs.Current if appEpoch != cur.EpochIndex() { - prev, ok := vs.Epochs.Prev.Get() - if !ok { - // NewEpochDuo requires Prev iff Current>0; AppQC at Current-1 - // with Current>0 must have Prev. - panic(fmt.Sprintf("appQC epoch %d needs Prev, but Prev is absent (Current %d)", appEpoch, cur.EpochIndex())) - } - ep = prev + ep = vs.Epochs.Prev.OrPanic("previous epoch required for previous-epoch AppQC") } if err := appQC.Verify(ep); err != nil { return fmt.Errorf("appQC: %w", err) diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index ca81b059d8..b4e0053123 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -724,13 +724,41 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { require.True(t, ok) require.Equal(t, EpochIndex(0), appPrev.EpochIndex()) - // AppQC outside {Current, Current-1} — not attached; keep in-window CommitQC App. - fpOut := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2)))) - require.False(t, fpOut.appQC.IsPresent()) - appOut, ok := fpOut.Proposal().Msg().App().Get() - require.True(t, ok) - require.True(t, vs.Epoch().AcceptsAppEpoch(appOut.EpochIndex())) - require.NoError(t, fpOut.Verify(vs)) + // AppQC outside {Current, Current-1} — rejected during construction. + _, err := NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2))) + require.Error(t, err) +} + +func TestProposalVerifyRejectsChangedCarriedAppProposal(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep := genFreshEpoch(rng, committee) + lane := keys[0].Public() + + laneQCs0 := map[LaneID]*LaneQC{lane: makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng))} + commitQC0 := makeCommitQC(keys, makeFullProposal(ep, keys, utils.None[*CommitQC](), laneQCs0, utils.None[*AppQC]())) + appQC0 := makeAppQCFor(keys, commitQC0.GlobalRange().First, commitQC0.Proposal().Index(), GenAppHash(rng), ep.EpochIndex()) + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: EpochDuoForTest(ep)} + commitQC1 := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), oneLaneQCMap(rng, committee, keys, vs1), utils.Some(appQC0))) + vs := ViewSpec{CommitQC: utils.Some(commitQC1), Epochs: EpochDuoForTest(ep)} + leader := leaderKey(committee, keys, vs.View()) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) + + msg := fp.Proposal().Msg() + carried := msg.App().OrPanic("fixture CommitQC carries an AppProposal") + changed := NewAppProposal( + carried.GlobalNumber(), + carried.RoadIndex(), + GenAppHash(rng), + carried.EpochIndex(), + ) + proposalPB := ProposalConv.Encode(msg) + proposalPB.App = AppProposalConv.Encode(changed) + changedProposal := utils.OrPanic1(ProposalConv.Decode(proposalPB)) + fullPB := FullProposalConv.Encode(fp) + fullPB.ProposalV2 = SignedProposalConv.Encode(Sign(leader, changedProposal)) + tampered := utils.OrPanic1(FullProposalConv.Decode(fullPB)) + require.Error(t, tampered.Verify(vs)) } func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index 72209e1ecf..d971be183d 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -10,17 +10,13 @@ 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] + qc utils.Option[*types.LaneQC] } // add credits vote weight until quorum. Returns a newly formed LaneQC iff this -// vote crosses quorum (built from votes; not cached on the set). +// vote crosses quorum and caches it for subsequent readers. func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { - if s.weight >= quorum { + if s.qc.IsPresent() { return utils.None[*types.LaneQC]() } s.weight += weight @@ -28,15 +24,12 @@ func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneV if s.weight < quorum { return utils.None[*types.LaneQC]() } - return utils.Some(types.NewLaneQC(s.votes)) + s.qc = utils.Some(types.NewLaneQC(s.votes)) + return s.qc } -// laneQC returns a LaneQC built from votes if weight has reached quorum. -func (s *laneVoteSet) laneQC(quorum uint64) utils.Option[*types.LaneQC] { - if s.weight < quorum { - return utils.None[*types.LaneQC]() - } - return utils.Some(types.NewLaneQC(s.votes)) +func (s *laneVoteSet) laneQC() utils.Option[*types.LaneQC] { + return s.qc } // blockVotes credits lane votes under the Current committee only. @@ -80,9 +73,8 @@ func (bv *blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVo // ctrl.Updated() after advanceEpoch (not via a return flag). func (bv *blockVotes) reweight(newEpoch *types.Epoch) { c := newEpoch.Committee() - for _, set := range bv.byHash { - set.reset() - } + clear(bv.byHash) + quorum := c.LaneQuorum() for k, vote := range bv.byKey { w := c.Weight(k) if w == 0 { @@ -95,13 +87,13 @@ func (bv *blockVotes) reweight(newEpoch *types.Epoch) { set = &laneVoteSet{header: vote.Msg().Header()} bv.byHash[h] = set } - set.add(w, c.LaneQuorum(), vote) + set.add(w, quorum, vote) } } -func (bv *blockVotes) laneQC(quorum uint64) utils.Option[*types.LaneQC] { +func (bv *blockVotes) laneQC() utils.Option[*types.LaneQC] { for _, set := range bv.byHash { - if qc, ok := set.laneQC(quorum).Get(); ok { + if qc, ok := set.laneQC().Get(); ok { return utils.Some(qc) } } diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index bc49962c8c..d1f4e679ce 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -85,15 +85,15 @@ func TestPushVote_CurrentCommitteeOnly(t *testing.T) { require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) qc, ok := bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).Get() require.True(t, ok) - gotQC, ok := bv.byHash[h].laneQC(ep0.Committee().LaneQuorum()).Get() + gotQC, ok := bv.byHash[h].laneQC().Get() require.True(t, ok) + require.True(t, qc == gotQC, "formed LaneQC should be cached") require.Equal(t, qc.Header().Hash(), gotQC.Header().Hash()) // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; A+B still form quorum under new weights. bv.reweight(ep1) - q := ep1.Committee().LaneQuorum() - require.True(t, bv.laneQC(q).IsPresent()) - require.True(t, bv.byHash[h].laneQC(q).IsPresent()) + require.True(t, bv.laneQC().IsPresent()) + require.True(t, bv.byHash[h].laneQC().IsPresent()) require.False(t, bv.pushVote(ep1, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) require.Contains(t, bv.byKey, keyE.Public()) require.Equal(t, header, bv.byHash[h].header) @@ -142,19 +142,21 @@ func TestPushVote_ReweightAfterAdvance(t *testing.T) { lane := keyA.Public() header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() h := header.Hash() + staleHeader := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() bv := newBlockVotes() require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) require.True(t, bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).IsPresent()) + require.False(t, bv.pushVote(ep0, types.Sign(keyC, types.NewLaneVote(staleHeader))).IsPresent()) bv.reweight(ep1) - q := ep1.Committee().LaneQuorum() require.Equal(t, uint64(1), bv.byHash[h].weight) require.Len(t, bv.byHash[h].votes, 1) require.Equal(t, keyA.Public(), bv.byHash[h].votes[0].Key()) - require.True(t, bv.byHash[h].laneQC(q).IsPresent()) - require.True(t, bv.laneQC(q).IsPresent()) + require.True(t, bv.byHash[h].laneQC().IsPresent()) + require.True(t, bv.laneQC().IsPresent()) require.NotContains(t, bv.byKey, keyB.Public(), "zero-weight signer removed from byKey") + require.NotContains(t, bv.byHash, staleHeader.Hash(), "hash with no current-committee votes removed") } func TestPushVote_CompetingHashesBothFormQC(t *testing.T) { @@ -168,8 +170,6 @@ func TestPushVote_CompetingHashesBothFormQC(t *testing.T) { ep := makeVoteEpoch(0, map[types.PublicKey]uint64{ keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, }) - q := ep.Committee().LaneQuorum() - lane := keyA.Public() header1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() header2 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() @@ -178,14 +178,14 @@ func TestPushVote_CompetingHashesBothFormQC(t *testing.T) { bv := newBlockVotes() require.False(t, bv.pushVote(ep, types.Sign(keyA, types.NewLaneVote(header1))).IsPresent()) require.True(t, bv.pushVote(ep, types.Sign(keyB, types.NewLaneVote(header1))).IsPresent()) - require.True(t, bv.byHash[header1.Hash()].laneQC(q).IsPresent()) + require.True(t, bv.byHash[header1.Hash()].laneQC().IsPresent()) // Competing hash forms its own LaneQC; both stay. require.False(t, bv.pushVote(ep, types.Sign(keyC, types.NewLaneVote(header2))).IsPresent()) require.True(t, bv.pushVote(ep, types.Sign(keyD, types.NewLaneVote(header2))).IsPresent()) - require.True(t, bv.byHash[header2.Hash()].laneQC(q).IsPresent()) + require.True(t, bv.byHash[header2.Hash()].laneQC().IsPresent()) require.Equal(t, header2, bv.byHash[header2.Hash()].header) require.Len(t, bv.byHash[header2.Hash()].votes, 2) - require.True(t, bv.byHash[header1.Hash()].laneQC(q).IsPresent()) - require.True(t, bv.laneQC(q).IsPresent()) + require.True(t, bv.byHash[header1.Hash()].laneQC().IsPresent()) + require.True(t, bv.laneQC().IsPresent()) } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 762fbbc5f6..ef16aaf937 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -98,8 +98,8 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) } lanes := map[types.LaneID]*laneState{} - // TODO(lane-id): also seed Prev lanes before prune so restart applies the - // anchor watermark to them (today only Current is pre-created; Prev lanes + // TODO(lane-id): also seed Prev lanes before pruning so restart applies the + // anchor boundary to them (today only Current is pre-created; Prev lanes // appear later via WAL getOrInsertLane and miss prune). Next Lane ID PR. for lane := range startEpochDuo.Current.Committee().Lanes().All() { lanes[lane] = newLaneState() @@ -122,10 +122,8 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. return i, nil } - // 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. + // Apply the persisted prune anchor first. It advances all queue boundaries, + // retains the anchor CommitQC, and sets appVotes.first from that CommitQC. if anchor, ok := l.pruneAnchor.Get(); ok { logger.Info("loaded persisted prune anchor", slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), @@ -134,13 +132,8 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. if err := verifyCommitQCInDuo(startEpochDuo, 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) - } - // 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) + if _, err := i.pushPruneAnchor(anchor); err != nil { + return nil, fmt.Errorf("push prune anchor: %w", err) } for lane, ls := range i.lanes { ls.persistedBlockStart = anchor.CommitQC.LaneRange(lane).First() @@ -153,7 +146,7 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } - // Restore persisted CommitQCs. prune() may have already pushed the + // Restore persisted CommitQCs. The prune anchor may have already pushed the // anchor's CommitQC, so skip entries below commitQCs.next. // Epoch must already be seeded. for _, lqc := range l.commitQCs { @@ -221,7 +214,7 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ if !ok { return utils.None[*types.LaneQC]() } - return bv.laneQC(i.epochDuo.Load().Current.Committee().LaneQuorum()) + return bv.laneQC() } // advanceEpoch installs nextDuo at a boundary. Sole post-construction writer of @@ -242,10 +235,10 @@ func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { i.epochDuo.Store(nextDuo) } -// insertCommitQCAtTip inserts qc at commitQCs.next. Returns false if idx is not +// pushCommitQC inserts qc at commitQCs.next. Returns false if idx is not // the tip (race / already applied). Does not advance epochDuo — runAdvanceEpoch // slides the window after tip passes Current's last road (intentional gap). -func (i *inner) insertCommitQCAtTip(qc *types.CommitQC) bool { +func (i *inner) pushCommitQC(qc *types.CommitQC) bool { idx := qc.Proposal().Index() if idx != i.commitQCs.next { return false @@ -255,11 +248,12 @@ func (i *inner) insertCommitQCAtTip(qc *types.CommitQC) bool { return true } -// 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) { +// pushPruneAnchor advances queue boundaries for an AppQC and its matching +// CommitQC, retaining the CommitQC when it is the next tip. Returns true when +// the anchor advanced, or false when it was stale. +func (i *inner) pushPruneAnchor(anchor *PruneAnchor) (bool, error) { + appQC := anchor.AppQC + commitQC := anchor.CommitQC 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()) @@ -270,6 +264,7 @@ func (i *inner) prune(appQC *types.AppQC, commitQC *types.CommitQC) (bool, error i.latestAppQC = utils.Some(appQC) metrics.ObserveAppQC(appQC) i.commitQCs.prune(idx) + i.pushCommitQC(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 c9ce358baa..aabd3463e5 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -56,10 +56,10 @@ func TestPruneMismatchedIndices(t *testing.T) { state, err = NewState(keys[0], ds, utils.Some(t.TempDir())) require.NoError(t, err) for inner := range state.inner.Lock() { - _, err := inner.prune(makeAppQC(qc1, qc0), qc1) + _, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: makeAppQC(qc1, qc0), CommitQC: 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(makeAppQC(qc1, qc1), qc1) + _, err = inner.pushPruneAnchor(&PruneAnchor{AppQC: makeAppQC(qc1, qc1), CommitQC: qc1}) require.NoError(t, err, "good range, good index should succeed") } } @@ -324,7 +324,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { require.True(t, ok) require.Equal(t, roadIdx, aq.Proposal().RoadIndex()) - // inner.prune(appQC@2, commitQC@2) sets commitQCs.first = 2. + // The prune anchor at road 2 sets commitQCs.first = 2. // Indices 2, 3 and 4 remain; earlier ones are pruned. require.Equal(t, types.RoadIndex(2), inner.commitQCs.first) require.Equal(t, types.RoadIndex(5), inner.commitQCs.next) @@ -446,7 +446,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(appQC, qcs[2]) + updated, err := i.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: qcs[2]}) require.NoError(t, err) require.True(t, updated) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 4ed3fea255..ee80efd99d 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -37,10 +37,6 @@ type State struct { // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. persisters persisters - - // startupWALPrune, if set, is the prune-anchor CommitQC used once at the - // start of runPersist to truncate WAL entries filtered out of memory at load. - startupWALPrune utils.Option[*types.CommitQC] } func (s *State) PublicKey() types.PublicKey { @@ -61,7 +57,7 @@ const innerFile = "avail_inner" // PruneAnchor is the decoded form of the persisted prune anchor // (AppQC + matching CommitQC pair). It serves as the crash-recovery -// pruning watermark. +// pruning boundary. type PruneAnchor struct { AppQC *types.AppQC CommitQC *types.CommitQC @@ -164,13 +160,8 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin // Tip order: consensus.NewState requires avail ≥ consensus; avail/consensus // may lag data and catch up in Run. commitTip := types.RoadIndex(0) - startupWALPrune := utils.None[*types.CommitQC]() if ls, ok := loaded.Get(); ok { commitTip = ls.nextCommitQC() - if anchor, ok := ls.pruneAnchor.Get(); ok { - // Disk truncate of filtered-out WAL entries runs once in runPersist. - startupWALPrune = utils.Some(anchor.CommitQC) - } } inner, err := newInner(data.Registry(), commitTip, loaded) if err != nil { @@ -178,12 +169,11 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin } return &State{ - key: key, - data: data, - inner: utils.NewWatch(inner), - epochDuo: inner.epochDuo.Subscribe(), - persisters: pers, - startupWALPrune: startupWALPrune, + key: key, + data: data, + inner: utils.NewWatch(inner), + epochDuo: inner.epochDuo.Subscribe(), + persisters: pers, }, nil } @@ -425,7 +415,7 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { } for inner, ctrl := range s.inner.Lock() { - if !inner.insertCommitQCAtTip(qc) { + if !inner.pushCommitQC(qc) { return nil } // latestCommitQC advances only after durable persist (or no-op persister). @@ -481,7 +471,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if !ok { return nil } - updated, err := inner.prune(appQC, qc) + updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: qc}) if err != nil { return err } @@ -538,15 +528,13 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ } } for inner, ctrl := range s.inner.Lock() { - updated, err := inner.prune(appQC, commitQC) + updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: commitQC}) if err != nil { return err } if !updated { return nil } - // prune advances pointers first; only then can pushBack land at idx. - inner.insertCommitQCAtTip(commitQC) ctrl.Updated() } return nil @@ -941,33 +929,18 @@ func (s *State) runAdvanceEpoch(ctx context.Context) error { // runPersist is the main loop for the persist goroutine. // Write order: -// 1. Prune anchor (AppQC + CommitQC pair) — the crash-recovery watermark (sequential). +// 1. Prune anchor (AppQC + CommitQC pair) — the crash-recovery boundary (sequential). // 2. commitQCs.MaybePruneAndPersist and each lane's blocks.MaybePruneAndPersistLane run // concurrently via scope.Parallel (separate WALs, no early cancellation; first error // is returned after all tasks finish). // Each path publishes (markCommitQCsPersisted / markBlockPersisted) per entry so voting // unblocks ASAP. // -// The prune anchor is a pruning watermark: on restart we resume from it. +// On restart, the persisted prune anchor establishes the retained boundary. // // TODO: use a single WAL for anchor and CommitQCs to make // this atomic rather than relying on write order. func (s *State) runPersist(ctx context.Context, pers persisters) error { - // Truncate WAL entries filtered out of memory at load (once). - // TODO(lane-id): also prune Prev committee lanes on restart (same as - // newInner Prev-lane seeding). Next Lane ID PR. - if anchorQC, ok := s.startupWALPrune.Get(); ok { - s.startupWALPrune = utils.None[*types.CommitQC]() - for lane := range s.epochDuo.Load().Current.Committee().Lanes().All() { - if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchorQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { - return fmt.Errorf("prune stale block WAL entries: %w", err) - } - } - if err := pers.commitQCs.MaybePruneAndPersist(utils.Some(anchorQC), nil, utils.None[func(*types.CommitQC)]()); err != nil { - return fmt.Errorf("prune stale commitQC WAL entries: %w", err) - } - } - var lastPersistedAppQCNext types.RoadIndex for { batch, err := s.collectPersistBatch(ctx, lastPersistedAppQCNext) @@ -975,10 +948,10 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { return err } - // Prune CommitQC anchor: same Option drives commit-QC WAL and per-lane block WAL + // The same anchor CommitQC drives commit-QC WAL and per-lane block WAL // (truncate-then-append below this QC). var anchorQC utils.Option[*types.CommitQC] - // 1. Persist prune anchor first — establishes the crash-recovery watermark. + // 1. Persist prune anchor first — establishes the crash-recovery boundary. if anchor, ok := batch.pruneAnchor.Get(); ok { if err := pers.pruneAnchor.Persist(PruneAnchorConv.Encode(anchor)); err != nil { return fmt.Errorf("persist prune anchor: %w", err) @@ -1040,7 +1013,7 @@ type persistBatch struct { pruneLanes []types.LaneID } -// advancePersistedBlockStart updates the per-lane block admission watermark +// advancePersistedBlockStart updates the per-lane block admission boundary // after durably writing the prune anchor. This unblocks PushBlock/ProduceBlock // waiters that are gated on persistedBlockStart + BlocksPerLane. func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { @@ -1085,10 +1058,10 @@ func (s *State) collectPersistBatch(ctx context.Context, lastPersistedAppQCNext for inner, ctrl := range s.inner.Lock() { // Derive the CommitQC persist cursor from latestCommitQC. This is // safe because latestCommitQC is only advanced by markCommitQCsPersisted - // (after disk write) and on startup (from disk). prune() does NOT - // update latestCommitQC, so this always reflects persistence state. - // The max clamp with commitQCs.first handles the case where prune() - // fast-forwarded the queue past the cursor. + // (after disk write) and on startup (from disk). Applying a prune anchor + // does not update latestCommitQC, so this always reflects persistence + // state. The max clamp with commitQCs.first handles an anchor + // fast-forwarding the queue past the cursor. commitQCNext := types.NextIndexOpt(inner.latestCommitQC.Load()) if err := ctrl.WaitUntil(ctx, func() bool { if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 9a542e1884..e5fd3e0f9e 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -899,7 +899,7 @@ func TestNewStateWithPersistence(t *testing.T) { state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) - // inner.prune(appQC@1, commitQC@1) sets commitQCs.first = 1. + // The prune anchor at road 1 sets commitQCs.first = 1. require.Equal(t, types.RoadIndex(1), state.FirstCommitQC()) require.NoError(t, utils.TestDiff(utils.Some(qcs[4]), state.LastCommitQC().Load())) }) From 9a770b329e3f1dd701b4d59e1ed708fa5392dbdc Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 30 Jul 2026 11:31:56 -0700 Subject: [PATCH 78/85] Split availability state by responsibility Separate app, commit, epoch-transition, lane, and persistence logic so the state orchestration remains reviewable. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/app.go | 151 +++ .../internal/autobahn/avail/commit.go | 98 ++ .../autobahn/avail/epoch_transition.go | 182 ++++ .../internal/autobahn/avail/lane.go | 279 +++++ .../internal/autobahn/avail/persistence.go | 299 ++++++ .../internal/autobahn/avail/state.go | 960 ------------------ 6 files changed, 1009 insertions(+), 960 deletions(-) create mode 100644 sei-tendermint/internal/autobahn/avail/app.go create mode 100644 sei-tendermint/internal/autobahn/avail/commit.go create mode 100644 sei-tendermint/internal/autobahn/avail/epoch_transition.go create mode 100644 sei-tendermint/internal/autobahn/avail/lane.go create mode 100644 sei-tendermint/internal/autobahn/avail/persistence.go diff --git a/sei-tendermint/internal/autobahn/avail/app.go b/sei-tendermint/internal/autobahn/avail/app.go new file mode 100644 index 0000000000..9cbbfa2759 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/app.go @@ -0,0 +1,151 @@ +package avail + +import ( + "context" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// LastAppQC returns the latest observed AppQC. +func (s *State) LastAppQC() utils.Option[*types.AppQC] { + for inner := range s.inner.Lock() { + return inner.latestAppQC + } + panic("unreachable") +} + +// WaitForAppQC waits until there is an AppQC for the given index or higher. +// Returns this AppQC and the corresponding CommitQC. +// Together they provide enough information to prune the availability state. +func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, *types.CommitQC, error) { + for inner, ctrl := range s.inner.Lock() { + for { + if appQC, ok := inner.latestAppQC.Get(); ok { + if x := appQC.Proposal().RoadIndex(); x >= idx && inner.commitQCs.next > x { + return appQC, inner.commitQCs.q[x], nil + } + } + if err := ctrl.Wait(ctx); err != nil { + return nil, nil, err + } + } + } + panic("unreachable") +} + +// PushAppVote pushes an AppVote to the state. +// Same admit-then-verify as PushAppQC: far-future roads park until the duo +// and CommitQC tip catch up (one stream goroutine; does not block others). +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 tip. + if err := s.waitForCommitQC(ctx, idx); err != nil { + return err + } + // Too-early roads (ahead of Prev|Current) backpressure; too-late are dropped. + admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppVote", idx) + if err != nil { + return err + } + duo, ok := admitted.Get() + if !ok { + return nil + } + ep := utils.OrPanic1(duo.EpochForRoad(idx)) + if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want { + return fmt.Errorf("appVote epoch_index %d, want %d", got, want) + } + committee := ep.Committee() + if err := v.VerifySig(committee); err != nil { + return fmt.Errorf("v.VerifySig(): %w", 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) { + return nil + } + // Verify the vote against the CommitQC. + qc := inner.commitQCs.q[idx] + if err := v.Msg().Proposal().Verify(qc); err != nil { + return fmt.Errorf("invalid vote: %w", err) + } + // Push the vote. + n := v.Msg().Proposal().GlobalNumber() + q := inner.appVotes + for q.next <= n { + q.pushBack(newAppVotes()) + } + appQC, ok := q.q[n].pushVote(committee, v) + if !ok { + return nil + } + updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: qc}) + if err != nil { + return err + } + if updated { + ctrl.Updated() + } + } + return nil +} + +// PushAppQC requires a justifying CommitQC. Epoch slide is async in +// runAdvanceEpoch (same as PushCommitQC). Prune before insert so latestAppQC is +// visible before the advance task observes the new tip. +// +// Same admit-then-verify as PushCommitQC. +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 + } + } + // Pair consistency only; ahead-of-window still waits in waitForEpochDuo. + 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() + admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppQC", idx) + if err != nil { + return err + } + duo, ok := admitted.Get() + if !ok { + return nil + } + ep := utils.OrPanic1(duo.EpochForRoad(idx)) + if err := appQC.Verify(ep); err != nil { + return fmt.Errorf("appQC.Verify(): %w", err) + } + if err := commitQC.Verify(ep); err != nil { + return fmt.Errorf("commitQC.Verify(): %w", err) + } + // Seal CommitQC paired with this AppQC: incoming AppQC satisfies the prune + // leash; still wait on the execution leash when idx closes Current. + if duo.Current.RoadRange().Next-1 == idx && ep.EpochIndex() == duo.Current.EpochIndex() { + if err := s.waitSealLeashes(ctx, duo.Current, idx, utils.Some(appQC.Proposal().EpochIndex())); err != nil { + return err + } + } + for inner, ctrl := range s.inner.Lock() { + updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: commitQC}) + if err != nil { + return err + } + if !updated { + return nil + } + ctrl.Updated() + } + return nil +} diff --git a/sei-tendermint/internal/autobahn/avail/commit.go b/sei-tendermint/internal/autobahn/avail/commit.go new file mode 100644 index 0000000000..ead7801778 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/commit.go @@ -0,0 +1,98 @@ +package avail + +import ( + "context" + "errors" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error { + _, err := s.LastCommitQC().Wait(ctx, func(qc utils.Option[*types.CommitQC]) bool { + return types.NextIndexOpt(qc) > idx + }) + return err +} + +// CommitQC returns the CommitQC for the given index. +func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.CommitQC, error) { + if err := s.waitForCommitQC(ctx, idx); err != nil { + return nil, err + } + for inner := range s.inner.Lock() { + if idx < inner.commitQCs.first { + return nil, types.ErrPruned + } + return inner.commitQCs.q[idx], nil + } + panic("unreachable") +} + +// PushCommitQC admits qc for Current only (too early waits; stale drops). +// Epoch slide is async in runAdvanceEpoch (tip may sit at Current.Next while +// Current still N; N+1 CommitQCs park on waitForEpoch until the duo advances). +// +// Seal (last road of Current): prune + execution leashes before admit +// (interlocking doc CommitQC admission). +// +// Admit-then-verify is intentional backpressure for ahead-of-window QCs. +func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { + idx := qc.Proposal().Index() + if idx > 0 { + if err := s.waitForCommitQC(ctx, idx-1); err != nil { + return err + } + } + admitted, err := s.waitForEpochOrDropStale(ctx, "CommitQC", idx) + if err != nil { + return err + } + duo, ok := admitted.Get() + if !ok { + return nil + } + ep := duo.Current + if err := qc.Verify(ep); err != nil { + return fmt.Errorf("qc.Verify(): %w", err) + } + if err := s.waitSealLeashes(ctx, ep, idx, utils.None[types.EpochIndex]()); err != nil { + return err + } + + for inner, ctrl := range s.inner.Lock() { + if !inner.pushCommitQC(qc) { + return nil + } + // latestCommitQC advances only after durable persist (or no-op persister). + ctrl.Updated() + return nil + } + return nil +} + +// fullCommitQC returns the FullCommitQC for road n. +// ErrRoadBeforeWindow → ErrPruned (export may jump ahead). ErrRoadAfterWindow hard-fails. +func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, error) { + qc, err := s.CommitQC(ctx, n) + if err != nil { + return nil, err + } + ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) + if err != nil { + if errors.Is(err, types.ErrRoadBeforeWindow) { + return nil, types.ErrPruned + } + return nil, err + } + var commitHeaders []*types.BlockHeader + for lane := range ep.Committee().Lanes().All() { + headers, err := s.headers(ctx, qc.LaneRange(lane)) + if err != nil { + return nil, err + } + commitHeaders = append(commitHeaders, headers...) + } + return types.NewFullCommitQC(qc, commitHeaders), nil +} diff --git a/sei-tendermint/internal/autobahn/avail/epoch_transition.go b/sei-tendermint/internal/autobahn/avail/epoch_transition.go new file mode 100644 index 0000000000..9f1ddcc297 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/epoch_transition.go @@ -0,0 +1,182 @@ +package avail + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { + // Debug: Info is too chatty at epoch boundaries (many peers a road behind). + logger.Debug("dropping stale "+what+": road behind window", + slog.Uint64("road", uint64(roadIdx)), "duo", duo.String()) +} + +// waitUntilRoad waits until status is not RoadFuture; RoadStale → ErrPruned +// with the deciding duo still returned for logging. +func (s *State) waitUntilRoad( + ctx context.Context, + roadIdx types.RoadIndex, + status func(types.EpochDuo) types.RoadStatus, +) (types.EpochDuo, error) { + duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { + return status(duo) != types.RoadFuture + }) + if err != nil { + return types.EpochDuo{}, err + } + switch status(duo) { + case types.RoadReady: + return duo, nil + case types.RoadStale: + return duo, types.ErrPruned + default: + // Wait predicate forbids Future; hitting it is an internal bug. + panic(fmt.Sprintf("waitUntilRoad: unexpected RoadFuture for road %d after Wait", roadIdx)) + } +} + +// waitForEpoch waits until roadIdx is in Current (CommitQC tip). +func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { + return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { + return d.RoadStatusCurrent(roadIdx) + }) +} + +// waitForEpochDuo waits until roadIdx is in Prev|Current (AppVote/AppQC). +func (s *State) waitForEpochDuo(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { + return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { + return d.RoadStatusDuo(roadIdx) + }) +} + +// waitForEpochOrDropStale is PushCommitQC admit: wait for Current, soft-drop if stale. +func (s *State) waitForEpochOrDropStale( + ctx context.Context, what string, roadIdx types.RoadIndex, +) (utils.Option[types.EpochDuo], error) { + return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitForEpoch) +} + +// waitForEpochDuoOrDropStale is PushAppVote/PushAppQC admit: wait for Prev|Current, soft-drop if stale. +func (s *State) waitForEpochDuoOrDropStale( + ctx context.Context, what string, roadIdx types.RoadIndex, +) (utils.Option[types.EpochDuo], error) { + return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitForEpochDuo) +} + +func (s *State) waitRoadOrDropStale( + ctx context.Context, + what string, + roadIdx types.RoadIndex, + wait func(context.Context, types.RoadIndex) (types.EpochDuo, error), +) (utils.Option[types.EpochDuo], error) { + duo, err := wait(ctx, roadIdx) + if err != nil { + if errors.Is(err, types.ErrPruned) { + logStaleRoad(what, roadIdx, duo) + return utils.None[types.EpochDuo](), nil + } + return utils.None[types.EpochDuo](), err + } + return utils.Some(duo), nil +} + +// waitForAppQC blocks until latest AppQC is from epochIdx or later. +// +// Seal prune leash (interlocking doc): Availability admits the last CommitQC of +// epoch N only after an AppQC for N exists. Also used by runAdvanceEpoch as a +// no-op once admit already waited. Epoch 0 is not special-cased: leaving 0 +// still needs an AppQC anchor for restart (Current>0 requires one). +func (s *State) waitForAppQC(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 sealing epoch", attrs...) + return ctrl.WaitUntil(ctx, ready) + } + panic("unreachable") +} + +// waitSealLeashes enforces the interlocking-doc seal conditions before admitting +// the last CommitQC of ep: AppQC for ep (unless incomingAppEpoch already +// satisfies) and registry WaitForDuo for the next road (execution leash). +func (s *State) waitSealLeashes( + ctx context.Context, + ep *types.Epoch, + idx types.RoadIndex, + incomingAppEpoch utils.Option[types.EpochIndex], +) error { + last := ep.RoadRange().Next - 1 + if idx != last { + return nil + } + if e, ok := incomingAppEpoch.Get(); !ok || e < ep.EpochIndex() { + if err := s.waitForAppQC(ctx, ep.EpochIndex()); err != nil { + return err + } + } + if _, err := s.data.Registry().WaitForDuo(ctx, last+1); err != nil { + return fmt.Errorf("WaitForDuo(%d): %w", last+1, err) + } + return nil +} + +// runAdvanceEpoch is the sole post-construction writer of epochDuo. When +// commitQCs tip passes Current's last road, seal leashes have already been +// satisfied at PushCommitQC/PushAppQC admit; this waits for tip, re-checks +// leashes (no-op if already met), then advances. N+1 CommitQCs park on +// waitForEpoch until the duo slides. +func (s *State) runAdvanceEpoch(ctx context.Context) error { + for { + duo := s.epochDuo.Load() + epochIdx := duo.Current.EpochIndex() + last := duo.Current.RoadRange().Next - 1 + + for inner, ctrl := range s.inner.Lock() { + if err := ctrl.WaitUntil(ctx, func() bool { + return inner.commitQCs.next > last + }); err != nil { + return err + } + } + + if err := s.waitForAppQC(ctx, epochIdx); err != nil { + return err + } + nextDuo, err := s.data.Registry().WaitForDuo(ctx, last+1) + if err != nil { + return err + } + + for inner, ctrl := range s.inner.Lock() { + live := inner.epochDuo.Load() + if live.Current.EpochIndex() != epochIdx { + break + } + if inner.commitQCs.next <= last { + break + } + inner.advanceEpoch(nextDuo) + ctrl.Updated() + } + } +} diff --git a/sei-tendermint/internal/autobahn/avail/lane.go b/sei-tendermint/internal/autobahn/avail/lane.go new file mode 100644 index 0000000000..d6c5322ad4 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/lane.go @@ -0,0 +1,279 @@ +package avail + +import ( + "context" + "fmt" + "log/slog" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// 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 ls, ok := inner.lanes[lane]; ok { + return ls.blocks.next + } + } + return 0 +} + +// Block returns block n of the given lane. +// Waits until the block is available. +// 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() { + 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 + } + if n < q.first { + return nil, types.ErrPruned + } + return q.q[n], nil + } + panic("unreachable") +} + +// PushBlock pushes a block to the state. +// Waits until all previous blocks are available. +func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LaneProposal]) error { + h := p.Msg().Block().Header() + if p.Key() != h.Lane() { + return fmt.Errorf("signer %v does not match lane %v", p.Key(), h.Lane()) + } + // Snapshot Current once for off-lock verify. Unlike PushVote (which parks + // until Current accepts the signer), we do not wait for future committees — + // lane proposals are not reweighted across epoch advances. + duo := s.epochDuo.Load() + c := duo.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.VerifySig(): %w", err) + } + for inner, ctrl := range s.inner.Lock() { + 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, ls.persistedBlockStart+BlocksPerLane-1) + }); err != nil { + return err + } + // not needed any more + if q.next != h.BlockNumber() { + return nil + } + // Verify parent hash chain to prevent a malicious producer from + // breaking the block chain, which would deadlock header reconstruction. + // A mismatch means the producer equivocated (produced a different + // chain than we already have). We log it to aid debugging stalled + // lanes but do not return an error — the caller should not tear + // down the peer connection over an equivocating producer. + // NOTE: after pruning (q.first >= q.next), we cannot verify the parent + // hash because the previous block is gone. This is safe because + // headers() never follows the first block's parentHash in a LaneRange. + if q.first < q.next { + prevHash := q.q[q.next-1].Msg().Block().Header().Hash() + if h.ParentHash() != prevHash { + logger.Error("parent hash mismatch (producer equivocation)", + "lane", h.Lane(), + slog.Uint64("block", uint64(h.BlockNumber())), + "got", h.ParentHash(), + "want", prevHash) + return nil + } + } + q.pushBack(p) + ctrl.Updated() + } + return nil +} + +// PushVote parks until Current can accept the vote (signer weight + voted lane), +// verifies, then under lock waits for capacity and credits with the live duo +// (drop if Current advanced and signer left). +// +// Lane-vote streams are committee-only (giga RunServer/RunClient), so parking a +// future-epoch signer does not expose an unauthenticated DoS path. No p2p retry: +// without this wait, async epoch entry would drop the vote permanently. +func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { + h := vote.Msg().Header() + // Future-epoch voters park (one stream goroutine) until Current includes them. + var committee *types.Committee + var verifiedEpoch types.EpochIndex + for inner, ctrl := range s.inner.Lock() { + if err := ctrl.WaitUntil(ctx, func() bool { + c := inner.epochDuo.Load().Current.Committee() + return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) + }); err != nil { + return err + } + duo := inner.epochDuo.Load() + committee = duo.Current.Committee() + verifiedEpoch = duo.Current.EpochIndex() + } + if err := vote.Msg().Verify(committee); err != nil { + return fmt.Errorf("vote.Verify(): %w", err) + } + if err := vote.VerifySig(committee); err != nil { + return fmt.Errorf("vote.Verify(): %w", err) + } + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes[h.Lane()] + if !ok { + return ErrBadLane + } + q := ls.votes + if err := ctrl.WaitUntil(ctx, func() bool { + return h.BlockNumber() < ls.persistedBlockStart+BlocksPerLane + }); err != nil { + return err + } + // WaitUntil may release the lock; re-check membership under live Current. + live := inner.epochDuo.Load() + if live.Current.EpochIndex() != verifiedEpoch && + (live.Current.Committee().Weight(vote.Key()) == 0 || + !live.Current.Committee().HasLane(h.Lane())) { + return nil + } + if h.BlockNumber() < q.first { + return nil + } + for q.next <= h.BlockNumber() { + q.pushBack(newBlockVotes()) + } + if q.q[h.BlockNumber()].pushVote(live.Current, vote).IsPresent() { + ctrl.Updated() + } + } + return nil +} + +// headers collects headers for the given range. +func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.BlockHeader, error) { + // Empty range is always available. + if lr.First() == lr.Next() { + return nil, nil + } + want := lr.LastHash() + headers := make([]*types.BlockHeader, lr.Next()-lr.First()) + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes[lr.Lane()] + if !ok { + return nil, types.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 { + // If pruned, then give up. + if q.first > lr.First() { + return nil, types.ErrPruned + } + if bv := q.q[n]; bv != nil { + if set, ok := bv.byHash[want]; ok { + want = set.header.ParentHash() + headers[len(headers)-i-1] = set.header + break + } + } + // Otherwise, wait. + if err := ctrl.Wait(ctx); err != nil { + return nil, err + } + } + } + } + return headers, nil +} + +// 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() + 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 < ls.persistedBlockStart+BlocksPerLane + }); err != nil { + return err + } + } + return nil +} + +// 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, 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.epochDuo.Load().Current + 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).Get(); ok { + laneQCs[lane] = qc + } else { + break + } + } + } + if len(laneQCs) > 0 { + return laneQCs, ep, nil + } + if err := ctrl.Wait(ctx); err != nil { + return nil, nil, err + } + } + } + panic("unreachable") +} + +// ProduceLocalBlock appends a new block to the producers lane. +// Fails in case there is not enough capacity in the lane, or it is not the next block expected. +func (s *State) ProduceLocalBlock(n types.BlockNumber, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { + return s.produceLocalBlock(n, s.key, payload) +} + +// TODO: produceLocalBlock is a separate function for testing - consider improving the tests to use ProduceBlock only. +func (s *State) produceLocalBlock(n types.BlockNumber, key types.SecretKey, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { + lane := key.Public() + var result *types.Signed[*types.LaneProposal] + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes[lane] + if !ok { + return nil, ErrBadLane + } + q := ls.blocks + if n >= ls.persistedBlockStart+BlocksPerLane { + return nil, fmt.Errorf("lane full") + } + if q.next != n { + return nil, fmt.Errorf("unexpected block number: got %v, want %v", n, q.next) + } + var parent types.BlockHeaderHash + if q.first < q.next { + parent = q.q[q.next-1].Msg().Block().Header().Hash() + } + result = types.Sign(key, types.NewLaneProposal(types.NewBlock(lane, q.next, parent, payload))) + q.pushBack(result) + ctrl.Updated() + } + return result, nil +} diff --git a/sei-tendermint/internal/autobahn/avail/persistence.go b/sei-tendermint/internal/autobahn/avail/persistence.go new file mode 100644 index 0000000000..7eeadf57c3 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/persistence.go @@ -0,0 +1,299 @@ +package avail + +import ( + "context" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" + 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" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" +) + +// persisters holds all disk persistence components. Either all are present +// (real I/O) or all are no-op (testing). It is a pure I/O struct — all inner +// state access goes through State methods. +type persisters struct { + pruneAnchor persist.Persister[*pb.PersistedAvailPruneAnchor] + blocks *persist.BlockPersister + commitQCs *persist.CommitQCPersister +} + +// innerFile is the A/B file prefix for avail inner state persistence. +const innerFile = "avail_inner" + +// PruneAnchor is the decoded form of the persisted prune anchor +// (AppQC + matching CommitQC pair). It serves as the crash-recovery +// pruning boundary. +type PruneAnchor struct { + AppQC *types.AppQC + CommitQC *types.CommitQC +} + +// PruneAnchorConv converts between PruneAnchor and its protobuf representation. +var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAnchor]{ + Encode: func(a *PruneAnchor) *pb.PersistedAvailPruneAnchor { + return &pb.PersistedAvailPruneAnchor{ + AppQc: types.AppQCConv.Encode(a.AppQC), + CommitQc: types.CommitQCConv.Encode(a.CommitQC), + } + }, + Decode: func(p *pb.PersistedAvailPruneAnchor) (*PruneAnchor, error) { + if p.AppQc == nil || p.CommitQc == nil { + return nil, fmt.Errorf("incomplete prune anchor: AppQC=%v CommitQC=%v", p.AppQc != nil, p.CommitQc != nil) + } + appQC, err := types.AppQCConv.Decode(p.AppQc) + if err != nil { + return nil, fmt.Errorf("decode AppQC: %w", err) + } + commitQC, err := types.CommitQCConv.Decode(p.CommitQc) + if err != nil { + return nil, fmt.Errorf("decode CommitQC: %w", err) + } + return &PruneAnchor{AppQC: appQC, CommitQC: commitQC}, nil + }, +} + +// loadPersistedState creates persisters for the given directory option and loads +// any existing state from disk. When dir is None, all persisters are no-op +// and no state is loaded. When a prune anchor is present, stale commitQCs and +// blocks below the anchor are filtered out before returning. +func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailState], persisters, error) { + prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + if err != nil { + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) + } + + bp, blocks, err := persist.NewBlockPersister(dir) + if err != nil { + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewBlockPersister: %w", err) + } + + cp, commitQCs, err := persist.NewCommitQCPersister(dir) + if err != nil { + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewCommitQCPersister: %w", err) + } + + pers := persisters{pruneAnchor: prunePersister, blocks: bp, commitQCs: cp} + + if _, ok := dir.Get(); !ok { + return utils.None[*loadedAvailState](), pers, nil + } + + loaded := &loadedAvailState{commitQCs: commitQCs, blocks: blocks} + + if raw, ok := persistedPruneAnchor.Get(); ok { + anchor, err := PruneAnchorConv.Decode(raw) + if err != nil { + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("decode prune anchor: %w", err) + } + loaded.pruneAnchor = utils.Some(anchor) + + anchorIdx := anchor.AppQC.Proposal().RoadIndex() + filtered := commitQCs[:0] + for _, lqc := range commitQCs { + if lqc.Index >= anchorIdx { + filtered = append(filtered, lqc) + } + } + loaded.commitQCs = filtered + + for lane, bs := range blocks { + first := anchor.CommitQC.LaneRange(lane).First() + j := 0 + for j < len(bs) && bs[j].Number < first { + j++ + } + if j > 0 { + loaded.blocks[lane] = bs[j:] + } + } + } + + return utils.Some(loaded), pers, nil +} + +// runPersist is the main loop for the persist goroutine. +// Write order: +// 1. Prune anchor (AppQC + CommitQC pair) — the crash-recovery boundary (sequential). +// 2. commitQCs.MaybePruneAndPersist and each lane's blocks.MaybePruneAndPersistLane run +// concurrently via scope.Parallel (separate WALs, no early cancellation; first error +// is returned after all tasks finish). +// Each path publishes (markCommitQCsPersisted / markBlockPersisted) per entry so voting +// unblocks ASAP. +// +// On restart, the persisted prune anchor establishes the retained boundary. +// +// TODO: use a single WAL for anchor and CommitQCs to make +// this atomic rather than relying on write order. +func (s *State) runPersist(ctx context.Context, pers persisters) error { + var lastPersistedAppQCNext types.RoadIndex + for { + batch, err := s.collectPersistBatch(ctx, lastPersistedAppQCNext) + if err != nil { + return err + } + + // The same anchor CommitQC drives commit-QC WAL and per-lane block WAL + // (truncate-then-append below this QC). + var anchorQC utils.Option[*types.CommitQC] + // 1. Persist prune anchor first — establishes the crash-recovery boundary. + if anchor, ok := batch.pruneAnchor.Get(); ok { + if err := pers.pruneAnchor.Persist(PruneAnchorConv.Encode(anchor)); err != nil { + return fmt.Errorf("persist prune anchor: %w", err) + } + s.advancePersistedBlockStart(anchor.CommitQC) + lastPersistedAppQCNext = anchor.CommitQC.Proposal().Index() + 1 + anchorQC = utils.Some(anchor.CommitQC) + } + + markBlock := func(p *types.Signed[*types.LaneProposal]) { + header := p.Msg().Block().Header() + s.markBlockPersisted(header.Lane(), header.BlockNumber()+1) + } + + blocksByLane := make(map[types.LaneID][]*types.Signed[*types.LaneProposal]) + for _, proposal := range batch.blocks { + lane := proposal.Msg().Block().Header().Lane() + blocksByLane[lane] = append(blocksByLane[lane], proposal) + } + + // 2. Persist commit-QCs and per-lane blocks in parallel. + // Callees handle empty inputs gracefully (no-op when nothing to write/truncate). + if err := scope.Parallel(func(ps scope.ParallelScope) error { + ps.Spawn(func() error { + return pers.commitQCs.MaybePruneAndPersist(anchorQC, batch.commitQCs, utils.Some(func(qc *types.CommitQC) { + s.markCommitQCsPersisted(qc) + })) + }) + // Collect lanes: any lane with blocks in this batch, plus all lanes + // in the anchor epoch (for WAL pruning). + // TODO: when epoch transitions land, also union in lanes from all + // epochs that appear in batch.commitQCs so new-epoch lanes are + // never skipped in a cross-epoch batch. + batchLanes := map[types.LaneID]struct{}{} + for lane := range blocksByLane { + batchLanes[lane] = struct{}{} + } + for _, lane := range batch.pruneLanes { + batchLanes[lane] = struct{}{} + } + for lane := range batchLanes { + proposals := blocksByLane[lane] + ps.Spawn(func() error { + return pers.blocks.MaybePruneAndPersistLane(lane, anchorQC, proposals, utils.Some(markBlock)) + }) + } + return nil + }); err != nil { + return err + } + } +} + +// persistBatch holds the data collected under lock for one persist iteration. +type persistBatch struct { + blocks []*types.Signed[*types.LaneProposal] + commitQCs []*types.CommitQC + pruneAnchor utils.Option[*PruneAnchor] + pruneLanes []types.LaneID +} + +// advancePersistedBlockStart updates the per-lane block admission boundary +// after durably writing the prune anchor. This unblocks PushBlock/ProduceBlock +// waiters that are gated on persistedBlockStart + BlocksPerLane. +func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { + for inner, ctrl := range s.inner.Lock() { + for lane, ls := range inner.lanes { + start := commitQC.LaneRange(lane).First() + if start > ls.persistedBlockStart { + ls.persistedBlockStart = start + } + } + ctrl.Updated() + } +} + +// markBlockPersisted advances the per-lane block persistence cursor. +// Called after each block is persisted so that RecvBatch (and therefore +// voting) can unblock as soon as the block is durable. Safe for concurrent +// callers (acquires s.inner lock internally). +func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes[lane] + if !ok { + return + } + ls.nextBlockToPersist = next + ctrl.Updated() + } +} + +// markCommitQCsPersisted publishes the latest persisted CommitQC, +// gating consensus from advancing until the QC is durable. +func (s *State) markCommitQCsPersisted(qc *types.CommitQC) { + for inner, ctrl := range s.inner.Lock() { + inner.latestCommitQC.Store(utils.Some(qc)) + ctrl.Updated() + } +} + +// collectPersistBatch waits for new blocks or commitQCs and collects them under lock. +func (s *State) collectPersistBatch(ctx context.Context, lastPersistedAppQCNext types.RoadIndex) (persistBatch, error) { + var b persistBatch + for inner, ctrl := range s.inner.Lock() { + // Derive the CommitQC persist cursor from latestCommitQC. This is + // safe because latestCommitQC is only advanced by markCommitQCsPersisted + // (after disk write) and on startup (from disk). Applying a prune anchor + // does not update latestCommitQC, so this always reflects persistence + // state. The max clamp with commitQCs.first handles an anchor + // fast-forwarding the queue past the cursor. + commitQCNext := types.NextIndexOpt(inner.latestCommitQC.Load()) + if err := ctrl.WaitUntil(ctx, func() bool { + if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { + return true + } + for _, ls := range inner.lanes { + if ls.nextBlockToPersist < ls.blocks.next { + return true + } + } + return commitQCNext < inner.commitQCs.next + }); err != nil { + return b, err + } + 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) + for n := commitQCNext; n < inner.commitQCs.next; n++ { + b.commitQCs = append(b.commitQCs, inner.commitQCs.q[n]) + } + if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { + if appQC, ok := inner.latestAppQC.Get(); ok { + idx := appQC.Proposal().RoadIndex() + if qc, ok := inner.commitQCs.q[idx]; ok { + b.pruneAnchor = utils.Some(&PruneAnchor{ + AppQC: appQC, + CommitQC: qc, + }) + // Capture under the same lock as the anchor so an epoch slide + // cannot move its committee out of the live duo before I/O. + ep, err := inner.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) + if err != nil { + return b, fmt.Errorf("EpochForRoad(%d): %w", qc.Proposal().Index(), err) + } + for lane := range ep.Committee().Lanes().All() { + b.pruneLanes = append(b.pruneLanes, lane) + } + } + } + } + } + return b, nil +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index ee80efd99d..cd766bd841 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -4,13 +4,9 @@ import ( "context" "errors" "fmt" - "log/slog" "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" - 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" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) @@ -43,109 +39,6 @@ func (s *State) PublicKey() types.PublicKey { return s.key.Public() } -// persisters holds all disk persistence components. Either all are present -// (real I/O) or all are no-op (testing). It is a pure I/O struct — all inner -// state access goes through State methods. -type persisters struct { - pruneAnchor persist.Persister[*pb.PersistedAvailPruneAnchor] - blocks *persist.BlockPersister - commitQCs *persist.CommitQCPersister -} - -// innerFile is the A/B file prefix for avail inner state persistence. -const innerFile = "avail_inner" - -// PruneAnchor is the decoded form of the persisted prune anchor -// (AppQC + matching CommitQC pair). It serves as the crash-recovery -// pruning boundary. -type PruneAnchor struct { - AppQC *types.AppQC - CommitQC *types.CommitQC -} - -// PruneAnchorConv converts between PruneAnchor and its protobuf representation. -var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAnchor]{ - Encode: func(a *PruneAnchor) *pb.PersistedAvailPruneAnchor { - return &pb.PersistedAvailPruneAnchor{ - AppQc: types.AppQCConv.Encode(a.AppQC), - CommitQc: types.CommitQCConv.Encode(a.CommitQC), - } - }, - Decode: func(p *pb.PersistedAvailPruneAnchor) (*PruneAnchor, error) { - if p.AppQc == nil || p.CommitQc == nil { - return nil, fmt.Errorf("incomplete prune anchor: AppQC=%v CommitQC=%v", p.AppQc != nil, p.CommitQc != nil) - } - appQC, err := types.AppQCConv.Decode(p.AppQc) - if err != nil { - return nil, fmt.Errorf("decode AppQC: %w", err) - } - commitQC, err := types.CommitQCConv.Decode(p.CommitQc) - if err != nil { - return nil, fmt.Errorf("decode CommitQC: %w", err) - } - return &PruneAnchor{AppQC: appQC, CommitQC: commitQC}, nil - }, -} - -// loadPersistedState creates persisters for the given directory option and loads -// any existing state from disk. When dir is None, all persisters are no-op -// and no state is loaded. When a prune anchor is present, stale commitQCs and -// blocks below the anchor are filtered out before returning. -func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailState], persisters, error) { - prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) - if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) - } - - bp, blocks, err := persist.NewBlockPersister(dir) - if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewBlockPersister: %w", err) - } - - cp, commitQCs, err := persist.NewCommitQCPersister(dir) - if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewCommitQCPersister: %w", err) - } - - pers := persisters{pruneAnchor: prunePersister, blocks: bp, commitQCs: cp} - - if _, ok := dir.Get(); !ok { - return utils.None[*loadedAvailState](), pers, nil - } - - loaded := &loadedAvailState{commitQCs: commitQCs, blocks: blocks} - - if raw, ok := persistedPruneAnchor.Get(); ok { - anchor, err := PruneAnchorConv.Decode(raw) - if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("decode prune anchor: %w", err) - } - loaded.pruneAnchor = utils.Some(anchor) - - anchorIdx := anchor.AppQC.Proposal().RoadIndex() - filtered := commitQCs[:0] - for _, lqc := range commitQCs { - if lqc.Index >= anchorIdx { - filtered = append(filtered, lqc) - } - } - loaded.commitQCs = filtered - - for lane, bs := range blocks { - first := anchor.CommitQC.LaneRange(lane).First() - j := 0 - for j < len(bs) && bs[j].Number < first { - j++ - } - if j > 0 { - loaded.blocks[lane] = bs[j:] - } - } - } - - return utils.Some(loaded), pers, nil -} - // NewState constructs a new availability state. // stateDir is None when persistence is disabled (testing only); a no-op // persist goroutine still runs to bump cursors without disk I/O. @@ -205,635 +98,6 @@ func (s *State) LastCommitQC() utils.AtomicRecv[utils.Option[*types.CommitQC]] { panic("unreachable") } -func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error { - _, err := s.LastCommitQC().Wait(ctx, func(qc utils.Option[*types.CommitQC]) bool { - return types.NextIndexOpt(qc) > idx - }) - return err -} - -func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { - // Debug: Info is too chatty at epoch boundaries (many peers a road behind). - logger.Debug("dropping stale "+what+": road behind window", - slog.Uint64("road", uint64(roadIdx)), "duo", duo.String()) -} - -// waitUntilRoad waits until status is not RoadFuture; RoadStale → ErrPruned -// with the deciding duo still returned for logging. -func (s *State) waitUntilRoad( - ctx context.Context, - roadIdx types.RoadIndex, - status func(types.EpochDuo) types.RoadStatus, -) (types.EpochDuo, error) { - duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { - return status(duo) != types.RoadFuture - }) - if err != nil { - return types.EpochDuo{}, err - } - switch status(duo) { - case types.RoadReady: - return duo, nil - case types.RoadStale: - return duo, types.ErrPruned - default: - // Wait predicate forbids Future; hitting it is an internal bug. - panic(fmt.Sprintf("waitUntilRoad: unexpected RoadFuture for road %d after Wait", roadIdx)) - } -} - -// waitForEpoch waits until roadIdx is in Current (CommitQC tip). -func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { - return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { - return d.RoadStatusCurrent(roadIdx) - }) -} - -// waitForEpochDuo waits until roadIdx is in Prev|Current (AppVote/AppQC). -func (s *State) waitForEpochDuo(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { - return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { - return d.RoadStatusDuo(roadIdx) - }) -} - -// waitForEpochOrDropStale is PushCommitQC admit: wait for Current, soft-drop if stale. -func (s *State) waitForEpochOrDropStale( - ctx context.Context, what string, roadIdx types.RoadIndex, -) (utils.Option[types.EpochDuo], error) { - return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitForEpoch) -} - -// waitForEpochDuoOrDropStale is PushAppVote/PushAppQC admit: wait for Prev|Current, soft-drop if stale. -func (s *State) waitForEpochDuoOrDropStale( - ctx context.Context, what string, roadIdx types.RoadIndex, -) (utils.Option[types.EpochDuo], error) { - return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitForEpochDuo) -} - -func (s *State) waitRoadOrDropStale( - ctx context.Context, - what string, - roadIdx types.RoadIndex, - wait func(context.Context, types.RoadIndex) (types.EpochDuo, error), -) (utils.Option[types.EpochDuo], error) { - duo, err := wait(ctx, roadIdx) - if err != nil { - if errors.Is(err, types.ErrPruned) { - logStaleRoad(what, roadIdx, duo) - return utils.None[types.EpochDuo](), nil - } - return utils.None[types.EpochDuo](), err - } - return utils.Some(duo), nil -} - -// waitForAppQC blocks until latest AppQC is from epochIdx or later. -// -// Seal prune leash (interlocking doc): Availability admits the last CommitQC of -// epoch N only after an AppQC for N exists. Also used by runAdvanceEpoch as a -// no-op once admit already waited. Epoch 0 is not special-cased: leaving 0 -// still needs an AppQC anchor for restart (Current>0 requires one). -func (s *State) waitForAppQC(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 sealing epoch", attrs...) - return ctrl.WaitUntil(ctx, ready) - } - panic("unreachable") -} - -// waitSealLeashes enforces the interlocking-doc seal conditions before admitting -// the last CommitQC of ep: AppQC for ep (unless incomingAppEpoch already -// satisfies) and registry WaitForDuo for the next road (execution leash). -func (s *State) waitSealLeashes( - ctx context.Context, - ep *types.Epoch, - idx types.RoadIndex, - incomingAppEpoch utils.Option[types.EpochIndex], -) error { - last := ep.RoadRange().Next - 1 - if idx != last { - return nil - } - if e, ok := incomingAppEpoch.Get(); !ok || e < ep.EpochIndex() { - if err := s.waitForAppQC(ctx, ep.EpochIndex()); err != nil { - return err - } - } - if _, err := s.data.Registry().WaitForDuo(ctx, last+1); err != nil { - return fmt.Errorf("WaitForDuo(%d): %w", last+1, err) - } - return nil -} - -// LastAppQC returns the latest observed AppQC. -func (s *State) LastAppQC() utils.Option[*types.AppQC] { - for inner := range s.inner.Lock() { - return inner.latestAppQC - } - panic("unreachable") -} - -// WaitForAppQC waits until there is an AppQC for the given index or higher. -// Returns this AppQC and the corresponding CommitQC. -// Together they provide enough information to prune the availability state. -func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, *types.CommitQC, error) { - for inner, ctrl := range s.inner.Lock() { - for { - if appQC, ok := inner.latestAppQC.Get(); ok { - if x := appQC.Proposal().RoadIndex(); x >= idx && inner.commitQCs.next > x { - return appQC, inner.commitQCs.q[x], nil - } - } - if err := ctrl.Wait(ctx); err != nil { - return nil, nil, err - } - } - } - panic("unreachable") -} - -// CommitQC returns the CommitQC for the given index. -func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.CommitQC, error) { - if err := s.waitForCommitQC(ctx, idx); err != nil { - return nil, err - } - for inner := range s.inner.Lock() { - if idx < inner.commitQCs.first { - return nil, types.ErrPruned - } - return inner.commitQCs.q[idx], nil - } - panic("unreachable") -} - -// PushCommitQC admits qc for Current only (too early waits; stale drops). -// Epoch slide is async in runAdvanceEpoch (tip may sit at Current.Next while -// Current still N; N+1 CommitQCs park on waitForEpoch until the duo advances). -// -// Seal (last road of Current): prune + execution leashes before admit -// (interlocking doc CommitQC admission). -// -// Admit-then-verify is intentional backpressure for ahead-of-window QCs. -func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { - idx := qc.Proposal().Index() - if idx > 0 { - if err := s.waitForCommitQC(ctx, idx-1); err != nil { - return err - } - } - admitted, err := s.waitForEpochOrDropStale(ctx, "CommitQC", idx) - if err != nil { - return err - } - duo, ok := admitted.Get() - if !ok { - return nil - } - ep := duo.Current - if err := qc.Verify(ep); err != nil { - return fmt.Errorf("qc.Verify(): %w", err) - } - if err := s.waitSealLeashes(ctx, ep, idx, utils.None[types.EpochIndex]()); err != nil { - return err - } - - for inner, ctrl := range s.inner.Lock() { - if !inner.pushCommitQC(qc) { - return nil - } - // latestCommitQC advances only after durable persist (or no-op persister). - ctrl.Updated() - return nil - } - return nil -} - -// PushAppVote pushes an AppVote to the state. -// Same admit-then-verify as PushAppQC: far-future roads park until the duo -// and CommitQC tip catch up (one stream goroutine; does not block others). -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 tip. - if err := s.waitForCommitQC(ctx, idx); err != nil { - return err - } - // Too-early roads (ahead of Prev|Current) backpressure; too-late are dropped. - admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppVote", idx) - if err != nil { - return err - } - duo, ok := admitted.Get() - if !ok { - return nil - } - ep := utils.OrPanic1(duo.EpochForRoad(idx)) - if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want { - return fmt.Errorf("appVote epoch_index %d, want %d", got, want) - } - committee := ep.Committee() - if err := v.VerifySig(committee); err != nil { - return fmt.Errorf("v.VerifySig(): %w", 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) { - return nil - } - // Verify the vote against the CommitQC. - qc := inner.commitQCs.q[idx] - if err := v.Msg().Proposal().Verify(qc); err != nil { - return fmt.Errorf("invalid vote: %w", err) - } - // Push the vote. - n := v.Msg().Proposal().GlobalNumber() - q := inner.appVotes - for q.next <= n { - q.pushBack(newAppVotes()) - } - appQC, ok := q.q[n].pushVote(committee, v) - if !ok { - return nil - } - updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: qc}) - if err != nil { - return err - } - if updated { - ctrl.Updated() - } - } - return nil -} - -// PushAppQC requires a justifying CommitQC. Epoch slide is async in -// runAdvanceEpoch (same as PushCommitQC). Prune before insert so latestAppQC is -// visible before the advance task observes the new tip. -// -// Same admit-then-verify as PushCommitQC. -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 - } - } - // Pair consistency only; ahead-of-window still waits in waitForEpochDuo. - 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() - admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppQC", idx) - if err != nil { - return err - } - duo, ok := admitted.Get() - if !ok { - return nil - } - ep := utils.OrPanic1(duo.EpochForRoad(idx)) - if err := appQC.Verify(ep); err != nil { - return fmt.Errorf("appQC.Verify(): %w", err) - } - if err := commitQC.Verify(ep); err != nil { - return fmt.Errorf("commitQC.Verify(): %w", err) - } - // Seal CommitQC paired with this AppQC: incoming AppQC satisfies the prune - // leash; still wait on the execution leash when idx closes Current. - if duo.Current.RoadRange().Next-1 == idx && ep.EpochIndex() == duo.Current.EpochIndex() { - if err := s.waitSealLeashes(ctx, duo.Current, idx, utils.Some(appQC.Proposal().EpochIndex())); err != nil { - return err - } - } - for inner, ctrl := range s.inner.Lock() { - updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: commitQC}) - if err != nil { - return err - } - if !updated { - return nil - } - ctrl.Updated() - } - return nil -} - -// 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 ls, ok := inner.lanes[lane]; ok { - return ls.blocks.next - } - } - return 0 -} - -// Block returns block n of the given lane. -// Waits until the block is available. -// 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() { - 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 - } - if n < q.first { - return nil, types.ErrPruned - } - return q.q[n], nil - } - panic("unreachable") -} - -// PushBlock pushes a block to the state. -// Waits until all previous blocks are available. -func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LaneProposal]) error { - h := p.Msg().Block().Header() - if p.Key() != h.Lane() { - return fmt.Errorf("signer %v does not match lane %v", p.Key(), h.Lane()) - } - // Snapshot Current once for off-lock verify. Unlike PushVote (which parks - // until Current accepts the signer), we do not wait for future committees — - // lane proposals are not reweighted across epoch advances. - duo := s.epochDuo.Load() - c := duo.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.VerifySig(): %w", err) - } - for inner, ctrl := range s.inner.Lock() { - 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, ls.persistedBlockStart+BlocksPerLane-1) - }); err != nil { - return err - } - // not needed any more - if q.next != h.BlockNumber() { - return nil - } - // Verify parent hash chain to prevent a malicious producer from - // breaking the block chain, which would deadlock header reconstruction. - // A mismatch means the producer equivocated (produced a different - // chain than we already have). We log it to aid debugging stalled - // lanes but do not return an error — the caller should not tear - // down the peer connection over an equivocating producer. - // NOTE: after pruning (q.first >= q.next), we cannot verify the parent - // hash because the previous block is gone. This is safe because - // headers() never follows the first block's parentHash in a LaneRange. - if q.first < q.next { - prevHash := q.q[q.next-1].Msg().Block().Header().Hash() - if h.ParentHash() != prevHash { - logger.Error("parent hash mismatch (producer equivocation)", - "lane", h.Lane(), - slog.Uint64("block", uint64(h.BlockNumber())), - "got", h.ParentHash(), - "want", prevHash) - return nil - } - } - q.pushBack(p) - ctrl.Updated() - } - return nil -} - -// PushVote parks until Current can accept the vote (signer weight + voted lane), -// verifies, then under lock waits for capacity and credits with the live duo -// (drop if Current advanced and signer left). -// -// Lane-vote streams are committee-only (giga RunServer/RunClient), so parking a -// future-epoch signer does not expose an unauthenticated DoS path. No p2p retry: -// without this wait, async epoch entry would drop the vote permanently. -func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { - h := vote.Msg().Header() - // Future-epoch voters park (one stream goroutine) until Current includes them. - var committee *types.Committee - var verifiedEpoch types.EpochIndex - for inner, ctrl := range s.inner.Lock() { - if err := ctrl.WaitUntil(ctx, func() bool { - c := inner.epochDuo.Load().Current.Committee() - return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) - }); err != nil { - return err - } - duo := inner.epochDuo.Load() - committee = duo.Current.Committee() - verifiedEpoch = duo.Current.EpochIndex() - } - if err := vote.Msg().Verify(committee); err != nil { - return fmt.Errorf("vote.Verify(): %w", err) - } - if err := vote.VerifySig(committee); err != nil { - return fmt.Errorf("vote.Verify(): %w", err) - } - for inner, ctrl := range s.inner.Lock() { - ls, ok := inner.lanes[h.Lane()] - if !ok { - return ErrBadLane - } - q := ls.votes - if err := ctrl.WaitUntil(ctx, func() bool { - return h.BlockNumber() < ls.persistedBlockStart+BlocksPerLane - }); err != nil { - return err - } - // WaitUntil may release the lock; re-check membership under live Current. - live := inner.epochDuo.Load() - if live.Current.EpochIndex() != verifiedEpoch && - (live.Current.Committee().Weight(vote.Key()) == 0 || - !live.Current.Committee().HasLane(h.Lane())) { - return nil - } - if h.BlockNumber() < q.first { - return nil - } - for q.next <= h.BlockNumber() { - q.pushBack(newBlockVotes()) - } - if q.q[h.BlockNumber()].pushVote(live.Current, vote).IsPresent() { - ctrl.Updated() - } - } - return nil -} - -// headers collects headers for the given range. -func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.BlockHeader, error) { - // Empty range is always available. - if lr.First() == lr.Next() { - return nil, nil - } - want := lr.LastHash() - headers := make([]*types.BlockHeader, lr.Next()-lr.First()) - for inner, ctrl := range s.inner.Lock() { - ls, ok := inner.lanes[lr.Lane()] - if !ok { - return nil, types.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 { - // If pruned, then give up. - if q.first > lr.First() { - return nil, types.ErrPruned - } - if bv := q.q[n]; bv != nil { - if set, ok := bv.byHash[want]; ok { - want = set.header.ParentHash() - headers[len(headers)-i-1] = set.header - break - } - } - // Otherwise, wait. - if err := ctrl.Wait(ctx); err != nil { - return nil, err - } - } - } - } - return headers, nil -} - -// fullCommitQC returns the FullCommitQC for road n. -// ErrRoadBeforeWindow → ErrPruned (export may jump ahead). ErrRoadAfterWindow hard-fails. -func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, error) { - qc, err := s.CommitQC(ctx, n) - if err != nil { - return nil, err - } - ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) - if err != nil { - if errors.Is(err, types.ErrRoadBeforeWindow) { - return nil, types.ErrPruned - } - return nil, err - } - var commitHeaders []*types.BlockHeader - for lane := range ep.Committee().Lanes().All() { - headers, err := s.headers(ctx, qc.LaneRange(lane)) - if err != nil { - return nil, err - } - commitHeaders = append(commitHeaders, headers...) - } - return types.NewFullCommitQC(qc, commitHeaders), nil -} - -// 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() - 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 < ls.persistedBlockStart+BlocksPerLane - }); err != nil { - return err - } - } - return nil -} - -// 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, 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.epochDuo.Load().Current - 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).Get(); ok { - laneQCs[lane] = qc - } else { - break - } - } - } - if len(laneQCs) > 0 { - return laneQCs, ep, nil - } - if err := ctrl.Wait(ctx); err != nil { - return nil, nil, err - } - } - } - panic("unreachable") -} - -// ProduceLocalBlock appends a new block to the producers lane. -// Fails in case there is not enough capacity in the lane, or it is not the next block expected. -func (s *State) ProduceLocalBlock(n types.BlockNumber, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { - return s.produceLocalBlock(n, s.key, payload) -} - -// TODO: produceLocalBlock is a separate function for testing - consider improving the tests to use ProduceBlock only. -func (s *State) produceLocalBlock(n types.BlockNumber, key types.SecretKey, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { - lane := key.Public() - var result *types.Signed[*types.LaneProposal] - for inner, ctrl := range s.inner.Lock() { - ls, ok := inner.lanes[lane] - if !ok { - return nil, ErrBadLane - } - q := ls.blocks - if n >= ls.persistedBlockStart+BlocksPerLane { - return nil, fmt.Errorf("lane full") - } - if q.next != n { - return nil, fmt.Errorf("unexpected block number: got %v, want %v", n, q.next) - } - var parent types.BlockHeaderHash - if q.first < q.next { - parent = q.q[q.next-1].Msg().Block().Header().Hash() - } - result = types.Sign(key, types.NewLaneProposal(types.NewBlock(lane, q.next, parent, payload))) - q.pushBack(result) - ctrl.Updated() - } - return result, nil -} - // Run runs the background tasks of the state. // // Goroutines: this method spawns long-lived goroutines via scope.SpawnNamed @@ -885,227 +149,3 @@ func (s *State) Run(ctx context.Context) error { return nil }) } - -// runAdvanceEpoch is the sole post-construction writer of epochDuo. When -// commitQCs tip passes Current's last road, seal leashes have already been -// satisfied at PushCommitQC/PushAppQC admit; this waits for tip, re-checks -// leashes (no-op if already met), then advances. N+1 CommitQCs park on -// waitForEpoch until the duo slides. -func (s *State) runAdvanceEpoch(ctx context.Context) error { - for { - duo := s.epochDuo.Load() - epochIdx := duo.Current.EpochIndex() - last := duo.Current.RoadRange().Next - 1 - - for inner, ctrl := range s.inner.Lock() { - if err := ctrl.WaitUntil(ctx, func() bool { - return inner.commitQCs.next > last - }); err != nil { - return err - } - } - - if err := s.waitForAppQC(ctx, epochIdx); err != nil { - return err - } - nextDuo, err := s.data.Registry().WaitForDuo(ctx, last+1) - if err != nil { - return err - } - - for inner, ctrl := range s.inner.Lock() { - live := inner.epochDuo.Load() - if live.Current.EpochIndex() != epochIdx { - break - } - if inner.commitQCs.next <= last { - break - } - inner.advanceEpoch(nextDuo) - ctrl.Updated() - } - } -} - -// runPersist is the main loop for the persist goroutine. -// Write order: -// 1. Prune anchor (AppQC + CommitQC pair) — the crash-recovery boundary (sequential). -// 2. commitQCs.MaybePruneAndPersist and each lane's blocks.MaybePruneAndPersistLane run -// concurrently via scope.Parallel (separate WALs, no early cancellation; first error -// is returned after all tasks finish). -// Each path publishes (markCommitQCsPersisted / markBlockPersisted) per entry so voting -// unblocks ASAP. -// -// On restart, the persisted prune anchor establishes the retained boundary. -// -// TODO: use a single WAL for anchor and CommitQCs to make -// this atomic rather than relying on write order. -func (s *State) runPersist(ctx context.Context, pers persisters) error { - var lastPersistedAppQCNext types.RoadIndex - for { - batch, err := s.collectPersistBatch(ctx, lastPersistedAppQCNext) - if err != nil { - return err - } - - // The same anchor CommitQC drives commit-QC WAL and per-lane block WAL - // (truncate-then-append below this QC). - var anchorQC utils.Option[*types.CommitQC] - // 1. Persist prune anchor first — establishes the crash-recovery boundary. - if anchor, ok := batch.pruneAnchor.Get(); ok { - if err := pers.pruneAnchor.Persist(PruneAnchorConv.Encode(anchor)); err != nil { - return fmt.Errorf("persist prune anchor: %w", err) - } - s.advancePersistedBlockStart(anchor.CommitQC) - lastPersistedAppQCNext = anchor.CommitQC.Proposal().Index() + 1 - anchorQC = utils.Some(anchor.CommitQC) - } - - markBlock := func(p *types.Signed[*types.LaneProposal]) { - header := p.Msg().Block().Header() - s.markBlockPersisted(header.Lane(), header.BlockNumber()+1) - } - - blocksByLane := make(map[types.LaneID][]*types.Signed[*types.LaneProposal]) - for _, proposal := range batch.blocks { - lane := proposal.Msg().Block().Header().Lane() - blocksByLane[lane] = append(blocksByLane[lane], proposal) - } - - // 2. Persist commit-QCs and per-lane blocks in parallel. - // Callees handle empty inputs gracefully (no-op when nothing to write/truncate). - if err := scope.Parallel(func(ps scope.ParallelScope) error { - ps.Spawn(func() error { - return pers.commitQCs.MaybePruneAndPersist(anchorQC, batch.commitQCs, utils.Some(func(qc *types.CommitQC) { - s.markCommitQCsPersisted(qc) - })) - }) - // Collect lanes: any lane with blocks in this batch, plus all lanes - // in the anchor epoch (for WAL pruning). - // TODO: when epoch transitions land, also union in lanes from all - // epochs that appear in batch.commitQCs so new-epoch lanes are - // never skipped in a cross-epoch batch. - batchLanes := map[types.LaneID]struct{}{} - for lane := range blocksByLane { - batchLanes[lane] = struct{}{} - } - for _, lane := range batch.pruneLanes { - batchLanes[lane] = struct{}{} - } - for lane := range batchLanes { - proposals := blocksByLane[lane] - ps.Spawn(func() error { - return pers.blocks.MaybePruneAndPersistLane(lane, anchorQC, proposals, utils.Some(markBlock)) - }) - } - return nil - }); err != nil { - return err - } - } -} - -// persistBatch holds the data collected under lock for one persist iteration. -type persistBatch struct { - blocks []*types.Signed[*types.LaneProposal] - commitQCs []*types.CommitQC - pruneAnchor utils.Option[*PruneAnchor] - pruneLanes []types.LaneID -} - -// advancePersistedBlockStart updates the per-lane block admission boundary -// after durably writing the prune anchor. This unblocks PushBlock/ProduceBlock -// waiters that are gated on persistedBlockStart + BlocksPerLane. -func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { - for inner, ctrl := range s.inner.Lock() { - for lane, ls := range inner.lanes { - start := commitQC.LaneRange(lane).First() - if start > ls.persistedBlockStart { - ls.persistedBlockStart = start - } - } - ctrl.Updated() - } -} - -// markBlockPersisted advances the per-lane block persistence cursor. -// Called after each block is persisted so that RecvBatch (and therefore -// voting) can unblock as soon as the block is durable. Safe for concurrent -// callers (acquires s.inner lock internally). -func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { - for inner, ctrl := range s.inner.Lock() { - ls, ok := inner.lanes[lane] - if !ok { - return - } - ls.nextBlockToPersist = next - ctrl.Updated() - } -} - -// markCommitQCsPersisted publishes the latest persisted CommitQC, -// gating consensus from advancing until the QC is durable. -func (s *State) markCommitQCsPersisted(qc *types.CommitQC) { - for inner, ctrl := range s.inner.Lock() { - inner.latestCommitQC.Store(utils.Some(qc)) - ctrl.Updated() - } -} - -// collectPersistBatch waits for new blocks or commitQCs and collects them under lock. -func (s *State) collectPersistBatch(ctx context.Context, lastPersistedAppQCNext types.RoadIndex) (persistBatch, error) { - var b persistBatch - for inner, ctrl := range s.inner.Lock() { - // Derive the CommitQC persist cursor from latestCommitQC. This is - // safe because latestCommitQC is only advanced by markCommitQCsPersisted - // (after disk write) and on startup (from disk). Applying a prune anchor - // does not update latestCommitQC, so this always reflects persistence - // state. The max clamp with commitQCs.first handles an anchor - // fast-forwarding the queue past the cursor. - commitQCNext := types.NextIndexOpt(inner.latestCommitQC.Load()) - if err := ctrl.WaitUntil(ctx, func() bool { - if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { - return true - } - for _, ls := range inner.lanes { - if ls.nextBlockToPersist < ls.blocks.next { - return true - } - } - return commitQCNext < inner.commitQCs.next - }); err != nil { - return b, err - } - 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) - for n := commitQCNext; n < inner.commitQCs.next; n++ { - b.commitQCs = append(b.commitQCs, inner.commitQCs.q[n]) - } - if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { - if appQC, ok := inner.latestAppQC.Get(); ok { - idx := appQC.Proposal().RoadIndex() - if qc, ok := inner.commitQCs.q[idx]; ok { - b.pruneAnchor = utils.Some(&PruneAnchor{ - AppQC: appQC, - CommitQC: qc, - }) - // Capture under the same lock as the anchor so an epoch slide - // cannot move its committee out of the live duo before I/O. - ep, err := inner.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) - if err != nil { - return b, fmt.Errorf("EpochForRoad(%d): %w", qc.Proposal().Index(), err) - } - for lane := range ep.Committee().Lanes().All() { - b.pruneLanes = append(b.pruneLanes, lane) - } - } - } - } - } - return b, nil -} From bbe37e132d53357922a57717b1e20eed505f1f34 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 30 Jul 2026 14:43:56 -0700 Subject: [PATCH 79/85] Make optional AppProposal verification panic-safe Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 83 +++++----- .../autobahn/types/proposal_test.go | 143 ++++++++++++++++-- .../internal/autobahn/epoch/registry_test.go | 2 +- 3 files changed, 177 insertions(+), 51 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index ce6c1fa3f4..1153550f40 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -377,13 +377,14 @@ func buildProposal( if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { timestamp = wantMin } - proposal := newProposal(viewSpec.View(), timestamp, laneRanges, app, viewSpec.NextGlobalBlock()) - if a, ok := proposal.App().Get(); ok && !proposal.acceptsAppEpoch(a.EpochIndex()) { - return nil, appQC, fmt.Errorf( - "app epoch_index %d incompatible with proposal epoch %d", - a.EpochIndex(), proposal.EpochIndex(), - ) + if a, ok := app.Get(); ok { + cur := viewSpec.Epoch().EpochIndex() + if a.EpochIndex() != cur && !(cur > 0 && a.EpochIndex() == cur-1) { + app = utils.None[*AppProposal]() + appQC = utils.None[*AppQC]() + } } + 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") } @@ -514,7 +515,11 @@ func (m *FullProposal) Verify(vs ViewSpec) error { } } proposalApp := proposal.App() - if app, ok := proposalApp.Get(); ok && !proposal.acceptsAppEpoch(app.EpochIndex()) { + app, appPresent := proposalApp.Get() + if !appPresent { + return nil + } + if !proposal.acceptsAppEpoch(app.EpochIndex()) { return fmt.Errorf( "app epoch_index %d incompatible with proposal epoch %d", app.EpochIndex(), proposal.EpochIndex(), @@ -523,45 +528,45 @@ func (m *FullProposal) Verify(vs ViewSpec) error { // Verify the appQC. previousApp := AppOpt(ProposalOpt(vs.CommitQC)) - if got, wantMin := NextOpt(proposalApp), NextOpt(previousApp); got < wantMin { - return errors.New("AppProposal lower than in previous CommitQC") - } else if got == wantMin { - if m.appQC.IsPresent() { - return errors.New("unnecessary appQC") + previous, previousPresent := previousApp.Get() + if previousPresent { + if app.RoadIndex() < previous.RoadIndex() { + return errors.New("AppProposal lower than in previous CommitQC") } - if app, ok := proposalApp.Get(); ok { - previous := previousApp.OrPanic("previous AppProposal is present when NextOpt matches") + if app.RoadIndex() == previous.RoadIndex() { + if m.appQC.IsPresent() { + return errors.New("unnecessary appQC") + } if NewHashed(NewAppVote(app)).hash != NewHashed(NewAppVote(previous)).hash { return errors.New("AppProposal differs from previous CommitQC") } - } - } else { - app := proposalApp.OrPanic("AppProposal is present when NextOpt increased") - appEpoch := app.EpochIndex() - cur := vs.Epoch() - appQC, ok := m.appQC.Get() - if !ok { - return errors.New("appQC missing") - } - if appQC.vote.hash != NewHashed(NewAppVote(app)).hash { - return errors.New("appQC doesn't match the proposal") - } - s.Spawn(func() error { - ep := vs.Epochs.Current - if appEpoch != cur.EpochIndex() { - ep = vs.Epochs.Prev.OrPanic("previous epoch required for previous-epoch AppQC") - } - if err := appQC.Verify(ep); err != nil { - return fmt.Errorf("appQC: %w", err) - } return nil - }) - if got, want := appQC.Proposal().RoadIndex(), vs.View().Index; got >= want { - return fmt.Errorf("appQC road %v ahead of tipcut view %v", got, want) } - if got, want := appQC.Proposal().GlobalNumber(), vs.NextGlobalBlock(); got >= want { - return fmt.Errorf("appQC for block %v, while only %v blocks were finalized", got, want) + } + appEpoch := app.EpochIndex() + cur := vs.Epoch() + appQC, ok := m.appQC.Get() + if !ok { + return errors.New("appQC missing") + } + if appQC.vote.hash != NewHashed(NewAppVote(app)).hash { + return errors.New("appQC doesn't match the proposal") + } + s.Spawn(func() error { + ep := vs.Epochs.Current + if appEpoch != cur.EpochIndex() { + ep = vs.Epochs.Prev.OrPanic("previous epoch required for previous-epoch AppQC") } + if err := appQC.Verify(ep); err != nil { + return fmt.Errorf("appQC: %w", err) + } + return nil + }) + if got, want := appQC.Proposal().RoadIndex(), vs.View().Index; got >= want { + return fmt.Errorf("appQC road %v ahead of tipcut view %v", got, want) + } + if got, want := appQC.Proposal().GlobalNumber(), vs.NextGlobalBlock(); got >= want { + return fmt.Errorf("appQC for block %v, while only %v blocks were finalized", got, want) } return nil }) diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index b4e0053123..e269bd843b 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -613,14 +613,11 @@ func makeCommitQC(keys []SecretKey, fullProposal *FullProposal) *CommitQC { return NewCommitQC(votes) } -func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { +func TestProposalVerifyAllowsAbsentAppProposal(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - // Construct commitQC for index 1 with AppProposal - // and Proposal for index 2 without any app proposal. - // Such a proposal should fail validation, because app proposals need to be monotone. l := keys[0].Public() 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]())) @@ -632,12 +629,52 @@ func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { 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. require.NoError(t, fp2a.Verify(vs)) - require.Error(t, fp2b.Verify(vs)) + require.NoError(t, fp2b.Verify(vs)) } -func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { +func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep := genFreshEpoch(rng, committee) + + commitQC0 := BuildCommitQC(ep, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + commitQC1 := BuildCommitQC(ep, keys, utils.Some(commitQC0), nil, utils.None[*AppQC]()) + appQC1 := makeAppQCFor( + keys, + commitQC1.GlobalRange().First, + commitQC1.Proposal().Index(), + GenAppHash(rng), + ep.EpochIndex(), + ) + commitQC2 := BuildCommitQC(ep, keys, utils.Some(commitQC1), nil, utils.Some(appQC1)) + vs := ViewSpec{CommitQC: utils.Some(commitQC2), Epochs: EpochDuoForTest(ep)} + leader := leaderKey(committee, keys, vs.View()) + fp := utils.OrPanic1(NewProposal( + leader, + vs, + time.Now(), + oneLaneQCMap(rng, committee, keys, vs), + utils.None[*AppQC](), + )) + + carried := fp.Proposal().Msg().App().OrPanic("fixture carries an AppProposal") + proposalPB := ProposalConv.Encode(fp.Proposal().Msg()) + proposalPB.App = AppProposalConv.Encode(NewAppProposal( + carried.GlobalNumber(), + carried.RoadIndex()-1, + carried.AppHash(), + carried.EpochIndex(), + )) + lowerProposal := utils.OrPanic1(ProposalConv.Decode(proposalPB)) + fullPB := FullProposalConv.Encode(fp) + fullPB.ProposalV2 = SignedProposalConv.Encode(Sign(leader, lowerProposal)) + lower := utils.OrPanic1(FullProposalConv.Decode(fullPB)) + + require.Error(t, lower.Verify(vs)) +} + +func TestProposalVerifyIgnoresAppQCWithoutAppProposal(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) @@ -655,7 +692,7 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { timeoutQC: fp.timeoutQC, } err := tamperedFP.Verify(vs) - require.Error(t, err) + require.NoError(t, err) } func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { @@ -724,9 +761,27 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { require.True(t, ok) require.Equal(t, EpochIndex(0), appPrev.EpochIndex()) - // AppQC outside {Current, Current-1} — rejected during construction. - _, err := NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(2))) - require.Error(t, err) + // AppQC outside {Current, Current-1} — omitted during construction. + outside := makeAppQCWithEpoch(2) + fpOutside := utils.OrPanic1(NewProposal( + leader, + vs, + time.Now(), + oneLaneQCMap(rng, committee, keys, vs), + utils.Some(outside), + )) + require.False(t, fpOutside.Proposal().Msg().App().IsPresent()) + require.False(t, fpOutside.appQC.IsPresent()) + require.NoError(t, fpOutside.Verify(vs)) + + // A leader that puts the incompatible App back on the wire is rejected. + proposalPB := ProposalConv.Encode(fpOutside.Proposal().Msg()) + proposalPB.App = AppProposalConv.Encode(outside.Proposal()) + wrongEpochProposal := utils.OrPanic1(ProposalConv.Decode(proposalPB)) + fullPB := FullProposalConv.Encode(fpOutside) + fullPB.ProposalV2 = SignedProposalConv.Encode(Sign(leader, wrongEpochProposal)) + wrongEpoch := utils.OrPanic1(FullProposalConv.Decode(fullPB)) + require.Error(t, wrongEpoch.Verify(vs)) } func TestProposalVerifyRejectsChangedCarriedAppProposal(t *testing.T) { @@ -761,6 +816,35 @@ func TestProposalVerifyRejectsChangedCarriedAppProposal(t *testing.T) { require.Error(t, tampered.Verify(vs)) } +func TestProposalVerifyRejectsWrappedAppRoadWithoutPanic(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep := genFreshEpoch(rng, committee) + vs := genesisViewSpec(rng, ep) + leader := leaderKey(committee, keys, vs.View()) + fp := utils.OrPanic1(NewProposal( + leader, + vs, + time.Now(), + oneLaneQCMap(rng, committee, keys, vs), + utils.None[*AppQC](), + )) + + proposalPB := ProposalConv.Encode(fp.Proposal().Msg()) + proposalPB.App = AppProposalConv.Encode(NewAppProposal( + 0, + utils.Max[RoadIndex](), + GenAppHash(rng), + ep.EpochIndex(), + )) + maliciousProposal := utils.OrPanic1(ProposalConv.Decode(proposalPB)) + fullPB := FullProposalConv.Encode(fp) + fullPB.ProposalV2 = SignedProposalConv.Encode(Sign(leader, maliciousProposal)) + malicious := utils.OrPanic1(FullProposalConv.Decode(fullPB)) + + require.Error(t, malicious.Verify(vs)) +} + func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) @@ -786,6 +870,43 @@ func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { require.NoError(t, fp.Verify(vs)) } +func TestProposalDropsStaleCarriedAppAfterEpochAdvance(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep0 := NewEpoch(0, RoadRange{First: 0, Next: 1}, committee) + ep1 := NewEpoch(1, RoadRange{First: 1, Next: 2}, committee) + ep2 := NewEpoch(2, RoadRange{First: 2, Next: utils.Max[RoadIndex]()}, committee) + + qc0 := BuildCommitQC(ep0, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + oldApp := makeAppQCFor( + keys, + qc0.GlobalRange().Next-1, + qc0.Proposal().Index(), + GenAppHash(rng), + ep0.EpochIndex(), + ) + qc1 := BuildCommitQC(ep1, keys, utils.Some(qc0), nil, utils.Some(oldApp)) + vs := ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(ep2, utils.Some(ep1))} + future := makeAppQCFor( + keys, + vs.NextGlobalBlock(), + vs.View().Index, + GenAppHash(rng), + ep2.EpochIndex(), + ) + + fp := utils.OrPanic1(NewProposal( + leaderKey(committee, keys, vs.View()), + vs, + time.Now(), + oneLaneQCMap(rng, committee, keys, vs), + utils.Some(future), + )) + require.False(t, fp.Proposal().Msg().App().IsPresent()) + require.False(t, fp.appQC.IsPresent()) + require.NoError(t, fp.Verify(vs)) +} + func TestProposalVerifyRejectsAppQCRoadNotBeforeView(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 32872f5be5..b758b642c8 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -6,7 +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" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) func makeRegistry(t *testing.T) (*Registry, *types.Committee) { From 1c3af7535a747e7df336b86d1114fc6c0816bf6c Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 30 Jul 2026 15:00:38 -0700 Subject: [PATCH 80/85] Adapt recovery tests to epoch-aware registry Co-authored-by: Cursor --- .../internal/autobahn/avail/state_test.go | 4 +-- .../autobahn/data/state_recovery_test.go | 28 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index e5fd3e0f9e..ddc42915f1 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -67,8 +67,8 @@ func makeAppVotes(keys []types.SecretKey, proposal *types.AppProposal) []*types. func TestSubscribeAppVotesJumpsToDataFloor(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc, blocks := data.TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc, blocks := data.TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr := qc.QC().GlobalRange() require.Greater(t, gr.Len(), uint64(2)) diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index 8fae14822c..bf263fb2dd 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -110,9 +110,9 @@ func TestRecoveryNormal(t *testing.T) { func TestRecoveryStartsAtLastExecutedBlock(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() require.Greater(t, gr2.Len(), 2) @@ -144,9 +144,9 @@ func TestRecoveryStartsAtLastExecutedBlock(t *testing.T) { func TestRecoveryCapsAppTipAtLastBlockInBlockDB(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -175,8 +175,8 @@ func TestRecoveryCapsAppTipAtLastBlockInBlockDB(t *testing.T) { func TestRecoveryRejectsAppTipBeyondCrashWindow(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) db := newTestBlockDB(t, t.TempDir()) writeToBlockDB(t, db, []*types.FullCommitQC{qc}, [][]*types.Block{blocks}) @@ -192,8 +192,8 @@ func TestRecoveryRejectsAppTipBeyondCrashWindow(t *testing.T) { func TestRecoveryStartsAtRegistryFloorWhenBlockDBMissingFirstCommittedBlock(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc, _ := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr := qc.QC().GlobalRange() db := &recoveryStartBlockDB{BlockDB: newTestBlockDB(t, t.TempDir())} @@ -211,7 +211,7 @@ func TestRecoveryStartsAtRegistryFloorWhenBlockDBMissingFirstCommittedBlock(t *t func TestRecoveryRejectsEmptyBlockDBAfterFirstCommittedBlock(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) lastExecuted := registry.FirstBlock() + 1 _, err := NewState(&Config{ @@ -223,9 +223,9 @@ func TestRecoveryRejectsEmptyBlockDBAfterFirstCommittedBlock(t *testing.T) { func TestRecoveryLeavesAppTipBelowPruneFloorUnreadable(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) db := newTestBlockDB(t, t.TempDir()) writeToBlockDB(t, db, From 3194e5ca656e61388cc65442406794dc769c0a78 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 30 Jul 2026 15:42:37 -0700 Subject: [PATCH 81/85] Clarify availability state ownership Group epoch, app, commit, and lane progress so each pipeline stage has explicit state boundaries while preserving existing behavior. Also fix the post-rebase recovery fixture and proposal lint failure. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 2 +- sei-tendermint/internal/autobahn/avail/app.go | 16 +- .../internal/autobahn/avail/app_progress.go | 12 ++ .../internal/autobahn/avail/commit.go | 9 +- .../autobahn/avail/commit_progress.go | 32 ++++ .../internal/autobahn/avail/epoch_progress.go | 11 ++ .../autobahn/avail/epoch_transition.go | 10 +- .../internal/autobahn/avail/inner.go | 158 ++++++---------- .../internal/autobahn/avail/inner_test.go | 168 +++++++++--------- .../internal/autobahn/avail/lane.go | 32 ++-- .../autobahn/avail/lane_collection.go | 93 ++++++++++ .../internal/autobahn/avail/persistence.go | 59 +++--- .../internal/autobahn/avail/state.go | 15 +- .../internal/autobahn/avail/state_test.go | 94 +++++----- .../internal/autobahn/avail/subscriptions.go | 4 +- .../internal/p2p/giga_router_common_test.go | 3 +- 16 files changed, 404 insertions(+), 314 deletions(-) create mode 100644 sei-tendermint/internal/autobahn/avail/app_progress.go create mode 100644 sei-tendermint/internal/autobahn/avail/commit_progress.go create mode 100644 sei-tendermint/internal/autobahn/avail/epoch_progress.go create mode 100644 sei-tendermint/internal/autobahn/avail/lane_collection.go diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 1153550f40..a45e14bc84 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -379,7 +379,7 @@ func buildProposal( } if a, ok := app.Get(); ok { cur := viewSpec.Epoch().EpochIndex() - if a.EpochIndex() != cur && !(cur > 0 && a.EpochIndex() == cur-1) { + if a.EpochIndex() != cur && (cur == 0 || a.EpochIndex() != cur-1) { app = utils.None[*AppProposal]() appQC = utils.None[*AppQC]() } diff --git a/sei-tendermint/internal/autobahn/avail/app.go b/sei-tendermint/internal/autobahn/avail/app.go index 9cbbfa2759..74fca5015c 100644 --- a/sei-tendermint/internal/autobahn/avail/app.go +++ b/sei-tendermint/internal/autobahn/avail/app.go @@ -11,7 +11,7 @@ import ( // LastAppQC returns the latest observed AppQC. func (s *State) LastAppQC() utils.Option[*types.AppQC] { for inner := range s.inner.Lock() { - return inner.latestAppQC + return inner.app.latestAppQC } panic("unreachable") } @@ -22,9 +22,9 @@ func (s *State) LastAppQC() utils.Option[*types.AppQC] { func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, *types.CommitQC, error) { for inner, ctrl := range s.inner.Lock() { for { - if appQC, ok := inner.latestAppQC.Get(); ok { - if x := appQC.Proposal().RoadIndex(); x >= idx && inner.commitQCs.next > x { - return appQC, inner.commitQCs.q[x], nil + if appQC, ok := inner.app.latestAppQC.Get(); ok { + if x := appQC.Proposal().RoadIndex(); x >= idx && inner.commits.qcs.next > x { + return appQC, inner.commits.qcs.q[x], nil } } if err := ctrl.Wait(ctx); err != nil { @@ -63,17 +63,17 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] } 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) { + if idx < types.NextOpt(inner.app.latestAppQC) { return nil } // Verify the vote against the CommitQC. - qc := inner.commitQCs.q[idx] + qc := inner.commits.qcs.q[idx] if err := v.Msg().Proposal().Verify(qc); err != nil { return fmt.Errorf("invalid vote: %w", err) } // Push the vote. n := v.Msg().Proposal().GlobalNumber() - q := inner.appVotes + q := inner.app.votes for q.next <= n { q.pushBack(newAppVotes()) } @@ -100,7 +100,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] 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() { + if types.NextOpt(inner.app.latestAppQC) > appQC.Proposal().RoadIndex() { return nil } } diff --git a/sei-tendermint/internal/autobahn/avail/app_progress.go b/sei-tendermint/internal/autobahn/avail/app_progress.go new file mode 100644 index 0000000000..dba3f760ce --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/app_progress.go @@ -0,0 +1,12 @@ +package avail + +import ( + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// appProgress owns the in-memory AppQC tip and AppVote accumulators. +type appProgress struct { + latestAppQC utils.Option[*types.AppQC] + votes *queue[types.GlobalBlockNumber, appVotes] +} diff --git a/sei-tendermint/internal/autobahn/avail/commit.go b/sei-tendermint/internal/autobahn/avail/commit.go index ead7801778..d04df157a7 100644 --- a/sei-tendermint/internal/autobahn/avail/commit.go +++ b/sei-tendermint/internal/autobahn/avail/commit.go @@ -9,6 +9,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// waitForCommitQC waits until the durable CommitQC tip has advanced past idx. func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error { _, err := s.LastCommitQC().Wait(ctx, func(qc utils.Option[*types.CommitQC]) bool { return types.NextIndexOpt(qc) > idx @@ -22,10 +23,10 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi return nil, err } for inner := range s.inner.Lock() { - if idx < inner.commitQCs.first { + if idx < inner.commits.qcs.first { return nil, types.ErrPruned } - return inner.commitQCs.q[idx], nil + return inner.commits.qcs.q[idx], nil } panic("unreachable") } @@ -62,10 +63,10 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { } for inner, ctrl := range s.inner.Lock() { - if !inner.pushCommitQC(qc) { + if !inner.commits.push(qc) { return nil } - // latestCommitQC advances only after durable persist (or no-op persister). + // persistedCommitQC advances only after durable persist (or no-op persister). ctrl.Updated() return nil } diff --git a/sei-tendermint/internal/autobahn/avail/commit_progress.go b/sei-tendermint/internal/autobahn/avail/commit_progress.go new file mode 100644 index 0000000000..b1ea6b832b --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/commit_progress.go @@ -0,0 +1,32 @@ +package avail + +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/libs/utils" +) + +// commitProgress owns the admitted CommitQC log and its durable tip. +// push advances only the admitted queue; only restore and markPersisted +// write persistedCommitQC. +type commitProgress struct { + qcs *queue[types.RoadIndex, *types.CommitQC] + persistedCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] +} + +// push inserts qc at qcs.next. Returns false if idx is not the tip +// (race / already applied). Does not write persistedCommitQC. +func (c *commitProgress) push(qc *types.CommitQC) bool { + idx := qc.Proposal().Index() + if idx != c.qcs.next { + return false + } + c.qcs.pushBack(qc) + metrics.ObserveCommitQC(qc) + return true +} + +// markPersisted publishes the latest durably persisted CommitQC. +func (c *commitProgress) markPersisted(qc *types.CommitQC) { + c.persistedCommitQC.Store(utils.Some(qc)) +} diff --git a/sei-tendermint/internal/autobahn/avail/epoch_progress.go b/sei-tendermint/internal/autobahn/avail/epoch_progress.go new file mode 100644 index 0000000000..d451989fd0 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/epoch_progress.go @@ -0,0 +1,11 @@ +package avail + +import ( + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// epochProgress owns the active Prev|Current admission window. +type epochProgress struct { + duo utils.AtomicSend[types.EpochDuo] // Store under Lock; State holds Recv +} diff --git a/sei-tendermint/internal/autobahn/avail/epoch_transition.go b/sei-tendermint/internal/autobahn/avail/epoch_transition.go index 9f1ddcc297..7bc4779a65 100644 --- a/sei-tendermint/internal/autobahn/avail/epoch_transition.go +++ b/sei-tendermint/internal/autobahn/avail/epoch_transition.go @@ -94,7 +94,7 @@ func (s *State) waitRoadOrDropStale( func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex) error { for inner, ctrl := range s.inner.Lock() { ready := func() bool { - appQC, ok := inner.latestAppQC.Get() + appQC, ok := inner.app.latestAppQC.Get() if !ok { return false } @@ -104,7 +104,7 @@ func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex) err return nil } attrs := []any{slog.Uint64("want_epoch", uint64(epochIdx))} - if appQC, ok := inner.latestAppQC.Get(); ok { + if appQC, ok := inner.app.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())), @@ -153,7 +153,7 @@ func (s *State) runAdvanceEpoch(ctx context.Context) error { for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { - return inner.commitQCs.next > last + return inner.commits.qcs.next > last }); err != nil { return err } @@ -168,11 +168,11 @@ func (s *State) runAdvanceEpoch(ctx context.Context) error { } for inner, ctrl := range s.inner.Lock() { - live := inner.epochDuo.Load() + live := inner.epoch.duo.Load() if live.Current.EpochIndex() != epochIdx { break } - if inner.commitQCs.next <= last { + if inner.commits.qcs.next <= last { break } inner.advanceEpoch(nextDuo) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index ef16aaf937..f20f7a83cf 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -12,58 +12,16 @@ import ( ) // TODO: when dynamic committee changes are supported, newly joined members -// must be added via getOrInsertLane (blocks, votes, nextBlockToPersist, and -// persistedBlockStart live on laneState). Currently lanes are initialized once -// in newInner from the start EpochDuo. BlockPersister creates lane WALs lazily -// inside MaybePruneAndPersistLane, but the new member must also appear in +// must be added via lanes.getOrInsert (blocks, votes, and durable cursors live +// on laneState). Currently lanes are initialized once in newInner from the +// start EpochDuo. BlockPersister creates lane WALs lazily inside +// MaybePruneAndPersistLane, but the new member must also appear in // inner.lanes before the next persist cycle. type inner struct { - latestAppQC utils.Option[*types.AppQC] - latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] - 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 -} - -type laneState struct { - blocks *queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] - votes *queue[types.BlockNumber, *blockVotes] - // nextBlockToPersist tracks 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). - // - // 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 types.BlockNumber - // persistedBlockStart is the block number derived from the last durably - // persisted prune anchor for this lane. 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 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 + epoch epochProgress + app appProgress + commits commitProgress + lanes laneCollection } // loadedAvailState holds data loaded from disk on restart. @@ -100,30 +58,36 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. lanes := map[types.LaneID]*laneState{} // TODO(lane-id): also seed Prev lanes before pruning so restart applies the // anchor boundary to them (today only Current is pre-created; Prev lanes - // appear later via WAL getOrInsertLane and miss prune). Next Lane ID PR. + // appear later via WAL getOrInsert and miss prune). Next Lane ID PR. 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]()), - epochDuo: utils.NewAtomicSend(startEpochDuo), - appVotes: newQueue[types.GlobalBlockNumber, appVotes](), - commitQCs: newQueue[types.RoadIndex, *types.CommitQC](), - lanes: lanes, + epoch: epochProgress{ + duo: utils.NewAtomicSend(startEpochDuo), + }, + app: appProgress{ + latestAppQC: utils.None[*types.AppQC](), + votes: newQueue[types.GlobalBlockNumber, appVotes](), + }, + commits: commitProgress{ + qcs: newQueue[types.RoadIndex, *types.CommitQC](), + persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + }, + lanes: laneCollection{byID: lanes}, } l, ok := loaded.Get() if !ok { if startEpochDuo.Current.EpochIndex() > 0 { return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } - i.appVotes.prune(registry.FirstBlock()) + i.app.votes.prune(registry.FirstBlock()) return i, nil } // Apply the persisted prune anchor first. It advances all queue boundaries, - // retains the anchor CommitQC, and sets appVotes.first from that CommitQC. + // retains the anchor CommitQC, and sets app.votes.first from that CommitQC. if anchor, ok := l.pruneAnchor.Get(); ok { logger.Info("loaded persisted prune anchor", slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), @@ -135,34 +99,34 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. if _, err := i.pushPruneAnchor(anchor); err != nil { return nil, fmt.Errorf("push prune anchor: %w", err) } - for lane, ls := range i.lanes { - ls.persistedBlockStart = anchor.CommitQC.LaneRange(lane).First() + for lane, ls := range i.lanes.byID { + ls.durable.persistedBlockFirst = anchor.CommitQC.LaneRange(lane).First() } } else if startEpochDuo.Current.EpochIndex() == 0 { - // No anchor: floor appVotes at genesis (registry), not tip Current — - // live advanceEpoch also leaves appVotes at the genesis floor. - i.appVotes.prune(registry.FirstBlock()) + // No anchor: floor app votes at genesis (registry), not tip Current — + // live advanceEpoch also leaves app.votes at the genesis floor. + i.app.votes.prune(registry.FirstBlock()) } else { return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) } // Restore persisted CommitQCs. The prune anchor may have already pushed the - // anchor's CommitQC, so skip entries below commitQCs.next. + // anchor's CommitQC, so skip entries below commits.qcs.next. // Epoch must already be seeded. for _, lqc := range l.commitQCs { - if lqc.Index < i.commitQCs.next { + if lqc.Index < i.commits.qcs.next { continue } - if lqc.Index != i.commitQCs.next { - return nil, fmt.Errorf("non-contiguous persisted commitQCs: expected %d, got %d", i.commitQCs.next, lqc.Index) + if lqc.Index != i.commits.qcs.next { + return nil, fmt.Errorf("non-contiguous persisted commitQCs: expected %d, got %d", i.commits.qcs.next, lqc.Index) } if err := verifyCommitQCInDuo(startEpochDuo, lqc.QC); err != nil { return nil, fmt.Errorf("load CommitQC %d: %w", lqc.Index, err) } - i.commitQCs.pushBack(lqc.QC) + i.commits.qcs.pushBack(lqc.QC) } - if i.commitQCs.next > i.commitQCs.first { - i.latestCommitQC.Store(utils.Some(i.commitQCs.q[i.commitQCs.next-1])) + if i.commits.qcs.next > i.commits.qcs.first { + i.commits.markPersisted(i.commits.qcs.q[i.commits.qcs.next-1]) } // Restore blocks; create queues for any WAL lane (including outside Current). @@ -171,7 +135,7 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. if len(bs) == 0 { continue } - ls := i.getOrInsertLane(lane) + ls := i.lanes.getOrInsert(lane) q := ls.blocks var lastHash types.BlockHeaderHash for j, b := range bs { @@ -190,7 +154,7 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. q.pushBack(b.Proposal) } if q.next > q.first { - ls.nextBlockToPersist = q.next + ls.durable.persistedBlockNext = q.next } } @@ -209,48 +173,28 @@ func verifyCommitQCInDuo(duo types.EpochDuo, qc *types.CommitQC) error { return nil } -func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { - bv, ok := i.lanes[lane].votes.q[n] - if !ok { - return utils.None[*types.LaneQC]() - } - return bv.laneQC() -} - // advanceEpoch installs nextDuo at a boundary. Sole post-construction writer of -// epochDuo (via runAdvanceEpoch). Caller must ensure nextDuo is the next epoch +// epoch.duo (via runAdvanceEpoch). Caller must ensure nextDuo is the next epoch // after Current and that seal leashes (waitForAppQC, registry WaitForDuo) are // already satisfied. Adds Current lanes; does not delete old lanes -// (TODO(lane-expiry)). +// (TODO(lane-expiry)). Touches epoch + lane votes (reweight). func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { current := nextDuo.Current for lane := range current.Committee().Lanes().All() { - i.getOrInsertLane(lane) + i.lanes.getOrInsert(lane) } - for _, ls := range i.lanes { + for _, ls := range i.lanes.byID { for n := ls.votes.first; n < ls.votes.next; n++ { ls.votes.q[n].reweight(current) } } - i.epochDuo.Store(nextDuo) -} - -// pushCommitQC inserts qc at commitQCs.next. Returns false if idx is not -// the tip (race / already applied). Does not advance epochDuo — runAdvanceEpoch -// slides the window after tip passes Current's last road (intentional gap). -func (i *inner) pushCommitQC(qc *types.CommitQC) bool { - idx := qc.Proposal().Index() - if idx != i.commitQCs.next { - return false - } - i.commitQCs.pushBack(qc) - metrics.ObserveCommitQC(qc) - return true + i.epoch.duo.Store(nextDuo) } // pushPruneAnchor advances queue boundaries for an AppQC and its matching // CommitQC, retaining the CommitQC when it is the next tip. Returns true when // the anchor advanced, or false when it was stale. +// Cross-progress orchestrator: touches app, commits, and lanes. func (i *inner) pushPruneAnchor(anchor *PruneAnchor) (bool, error) { appQC := anchor.AppQC commitQC := anchor.CommitQC @@ -258,21 +202,19 @@ func (i *inner) pushPruneAnchor(anchor *PruneAnchor) (bool, error) { if idx != commitQC.Proposal().Index() { return false, fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", idx, commitQC.Proposal().Index()) } - if idx < types.NextOpt(i.latestAppQC) { + if idx < types.NextOpt(i.app.latestAppQC) { return false, nil } - i.latestAppQC = utils.Some(appQC) + i.app.latestAppQC = utils.Some(appQC) metrics.ObserveAppQC(appQC) - i.commitQCs.prune(idx) - i.pushCommitQC(commitQC) - i.appVotes.prune(commitQC.GlobalRange().First) - for lane, ls := range i.lanes { + i.commits.qcs.prune(idx) + i.commits.push(commitQC) + i.app.votes.prune(commitQC.GlobalRange().First) + for lane, ls := range i.lanes.byID { lr := commitQC.LaneRange(lane) ls.votes.prune(lr.First()) ls.blocks.prune(lr.First()) - if ls.nextBlockToPersist < lr.First() { - ls.nextBlockToPersist = lr.First() - } + ls.durable.floorNext(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 aabd3463e5..12857731cf 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -58,7 +58,7 @@ func TestPruneMismatchedIndices(t *testing.T) { for inner := range state.inner.Lock() { _, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: makeAppQC(qc1, qc0), CommitQC: qc1}) require.Error(t, err, "good range, bad index should fail") - require.False(t, inner.latestAppQC.IsPresent(), "latestAppQC should not have been updated") + require.False(t, inner.app.latestAppQC.IsPresent(), "latestAppQC should not have been updated") _, err = inner.pushPruneAnchor(&PruneAnchor{AppQC: makeAppQC(qc1, qc1), CommitQC: qc1}) require.NoError(t, err, "good range, good index should succeed") } @@ -84,17 +84,17 @@ func TestNewInnerFreshStart(t *testing.T) { i, err := newInner(registry, 0, tc.loaded) require.NoError(t, err) - require.False(t, i.latestAppQC.IsPresent()) - 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) + require.False(t, i.app.latestAppQC.IsPresent()) + require.NotNil(t, i.lanes.byID) + require.Equal(t, types.RoadIndex(0), i.commits.qcs.first) + require.Equal(t, types.RoadIndex(0), i.commits.qcs.next) + require.Equal(t, registry.FirstBlock(), i.app.votes.first) + require.Equal(t, registry.FirstBlock(), i.app.votes.next) for lane := range registry.LatestEpoch().Committee().Lanes().All() { - 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) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[lane].blocks.first) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[lane].blocks.next) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[lane].votes.first) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[lane].votes.next) } }) } @@ -150,19 +150,19 @@ func TestNewInnerLoadedBlocksContiguous(t *testing.T) { i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - q := i.lanes[lane].blocks + q := i.lanes.byID[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(3), q.next) for j, b := range bs { require.Equal(t, b.Proposal, q.q[types.BlockNumber(j)]) } - // nextBlockToPersist: loaded lane at q.next, other lanes at 0 (map zero-value). - require.NotNil(t, i.lanes) - require.Equal(t, types.BlockNumber(3), i.lanes[lane].nextBlockToPersist) + // persistedBlockNext: loaded lane at q.next, other lanes at 0 (map zero-value). + require.NotNil(t, i.lanes.byID) + require.Equal(t, types.BlockNumber(3), i.lanes.byID[lane].durable.persistedBlockNext) for other := range registry.LatestEpoch().Committee().Lanes().All() { if other != lane { - require.Equal(t, types.BlockNumber(0), i.lanes[other].nextBlockToPersist) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[other].durable.persistedBlockNext) } } } @@ -179,7 +179,7 @@ func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - q := i.lanes[lane].blocks + q := i.lanes.byID[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(0), q.next) } @@ -200,7 +200,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { require.NoError(t, err) for lane := range registry.LatestEpoch().Committee().Lanes().All() { - q := i.lanes[lane].blocks + q := i.lanes.byID[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(0), q.next) } @@ -236,17 +236,17 @@ func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - q0 := i.lanes[lane0].blocks + q0 := i.lanes.byID[lane0].blocks require.Equal(t, types.BlockNumber(0), q0.first) require.Equal(t, types.BlockNumber(2), q0.next) - q1 := i.lanes[lane1].blocks + q1 := i.lanes.byID[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.lanes[lane0].nextBlockToPersist) - require.Equal(t, types.BlockNumber(3), i.lanes[lane1].nextBlockToPersist) + // persistedBlockNext reflects q.next per loaded lane. + require.Equal(t, types.BlockNumber(2), i.lanes.byID[lane0].durable.persistedBlockNext) + require.Equal(t, types.BlockNumber(3), i.lanes.byID[lane1].durable.persistedBlockNext) } func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { @@ -274,14 +274,14 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { require.NoError(t, err) // Without anchor, commitQCs.first = 0. All 3 should be restored. - require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(3), inner.commitQCs.next) + require.Equal(t, types.RoadIndex(0), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(3), inner.commits.qcs.next) for i, qc := range qcs { - require.NoError(t, utils.TestDiff(qc, inner.commitQCs.q[types.RoadIndex(i)])) + require.NoError(t, utils.TestDiff(qc, inner.commits.qcs.q[types.RoadIndex(i)])) } - // latestCommitQC should be set to the last loaded one. - latest, ok := inner.latestCommitQC.Load().Get() + // persistedCommitQC should be set to the last loaded one. + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[2], latest)) } @@ -320,20 +320,20 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { require.NoError(t, err) // latestAppQC should be set by prune. - aq, ok := inner.latestAppQC.Get() + aq, ok := inner.app.latestAppQC.Get() require.True(t, ok) require.Equal(t, roadIdx, aq.Proposal().RoadIndex()) // The prune anchor at road 2 sets commitQCs.first = 2. // Indices 2, 3 and 4 remain; earlier ones are pruned. - require.Equal(t, types.RoadIndex(2), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(5), inner.commitQCs.next) - require.NoError(t, utils.TestDiff(qcs[2], inner.commitQCs.q[2])) - require.NoError(t, utils.TestDiff(qcs[3], inner.commitQCs.q[3])) - require.NoError(t, utils.TestDiff(qcs[4], inner.commitQCs.q[4])) - - // latestCommitQC should be the last restored one (index 4). - latest, ok := inner.latestCommitQC.Load().Get() + require.Equal(t, types.RoadIndex(2), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(5), inner.commits.qcs.next) + require.NoError(t, utils.TestDiff(qcs[2], inner.commits.qcs.q[2])) + require.NoError(t, utils.TestDiff(qcs[3], inner.commits.qcs.q[3])) + require.NoError(t, utils.TestDiff(qcs[4], inner.commits.qcs.q[4])) + + // persistedCommitQC should be the last restored one (index 4). + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[4], latest)) } @@ -381,22 +381,22 @@ func TestNewInnerLoadedAllThree(t *testing.T) { require.NoError(t, err) // AppQC restored. - aq, ok := inner.latestAppQC.Get() + aq, ok := inner.app.latestAppQC.Get() require.True(t, ok) require.Equal(t, roadIdx, aq.Proposal().RoadIndex()) // CommitQCs: prune pushed qcs[2], loading skipped it, added 3 and 4. - require.Equal(t, types.RoadIndex(2), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(5), inner.commitQCs.next) + require.Equal(t, types.RoadIndex(2), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(5), inner.commits.qcs.next) // Blocks loaded. - q := inner.lanes[lane].blocks + q := inner.lanes.byID[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.lanes[lane].nextBlockToPersist) + require.Equal(t, types.BlockNumber(3), inner.lanes.byID[lane].durable.persistedBlockNext) - // latestCommitQC is the last loaded one. - latest, ok := inner.latestCommitQC.Load().Get() + // persistedCommitQC is the last loaded one. + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[4], latest)) } @@ -414,10 +414,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.lanes[lane].blocks.pushBack(b) + i.lanes.byID[lane].blocks.pushBack(b) } // Simulate partial persistence: only block 0 persisted. - i.lanes[lane].nextBlockToPersist = 1 + i.lanes.byID[lane].durable.persistedBlockNext = 1 // Build CommitQCs with lane ranges that reference actual blocks. // Each CommitQC covers one block on the lane via a LaneQC. @@ -425,7 +425,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { prev := utils.None[*types.CommitQC]() for j := range qcs { bn := types.BlockNumber(j) - h := i.lanes[lane].blocks.q[bn].Msg().Block().Header() + h := i.lanes.byID[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()), @@ -434,7 +434,7 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { } qcs[j] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, laneQCs, utils.None[*types.AppQC]()) prev = utils.Some(qcs[j]) - i.commitQCs.pushBack(qcs[j]) + i.commits.qcs.pushBack(qcs[j]) } // Verify QC@2's lane range actually covers blocks (First > 0). @@ -450,14 +450,14 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { require.NoError(t, err) require.True(t, updated) - // nextBlockToPersist must have advanced to at least the lane's new first + // persistedBlockNext 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.lanes[lane].blocks.first + laneFirst := i.lanes.byID[lane].blocks.first require.Greater(t, laneFirst, types.BlockNumber(1), "prune should have advanced blocks.first past the old cursor") - require.GreaterOrEqual(t, i.lanes[lane].nextBlockToPersist, laneFirst, - "nextBlockToPersist should advance when prune moves blocks.first past it") + require.GreaterOrEqual(t, i.lanes.byID[lane].durable.persistedBlockNext, laneFirst, + "persistedBlockNext should advance when prune moves blocks.first past it") } func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { @@ -485,9 +485,9 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { require.NoError(t, err) // 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])) + require.Equal(t, types.RoadIndex(5), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(6), inner.commits.qcs.next) + require.NoError(t, utils.TestDiff(qcs[5], inner.commits.qcs.q[5])) } func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { @@ -514,19 +514,19 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { require.NoError(t, err) // 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])) + require.Equal(t, types.RoadIndex(3), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(4), inner.commits.qcs.next) + require.NoError(t, utils.TestDiff(qcs[3], inner.commits.qcs.q[3])) // latestAppQC should be set. - aq, ok := inner.latestAppQC.Get() + aq, ok := inner.app.latestAppQC.Get() require.True(t, ok) require.Equal(t, types.RoadIndex(3), aq.Proposal().RoadIndex()) - // persistedBlockStart should be initialized from the anchor's CommitQC. + // persistedBlockFirst 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.lanes[lane].persistedBlockStart) + require.Equal(t, expected, inner.lanes.byID[lane].durable.persistedBlockFirst) } } @@ -569,9 +569,9 @@ func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(0), inner.commitQCs.next) - _, ok := inner.latestCommitQC.Load().Get() + require.Equal(t, types.RoadIndex(0), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(0), inner.commits.qcs.next) + _, ok := inner.commits.persistedCommitQC.Load().Get() require.False(t, ok) } @@ -605,16 +605,16 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { require.NoError(t, err) // Only QC@10 loaded. - require.Equal(t, types.RoadIndex(10), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(11), inner.commitQCs.next) - require.NoError(t, utils.TestDiff(qcs[10], inner.commitQCs.q[10])) + require.Equal(t, types.RoadIndex(10), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(11), inner.commits.qcs.next) + require.NoError(t, utils.TestDiff(qcs[10], inner.commits.qcs.q[10])) - latest, ok := inner.latestCommitQC.Load().Get() + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[10], latest)) // AppQC should be applied via prune. - aq, ok := inner.latestAppQC.Get() + aq, ok := inner.app.latestAppQC.Get() require.True(t, ok) require.Equal(t, types.RoadIndex(10), aq.Proposal().RoadIndex()) } @@ -654,9 +654,9 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { require.NoError(t, err) // 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() + require.Equal(t, types.RoadIndex(3), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(6), inner.commits.qcs.next) + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[5], latest)) } @@ -816,7 +816,7 @@ 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.lanes[l].blocks.first, + require.Equal(t, expected, i.lanes.byID[l].blocks.first, "lanes[%v].blocks.first should be advanced by prune to prune anchor lane range", l) } } @@ -850,9 +850,9 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { require.NoError(t, err) // prune(appQC@1, pruneQC@1) should advance commitQCs.first to 1. - require.Equal(t, types.RoadIndex(1), i.commitQCs.first) + require.Equal(t, types.RoadIndex(1), i.commits.qcs.first) // CommitQCs 1 and 2 should still be loaded. - require.Equal(t, types.RoadIndex(3), i.commitQCs.next) + require.Equal(t, types.RoadIndex(3), i.commits.qcs.next) } // TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock covers tip-based restart: @@ -884,9 +884,9 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { inner, err := newInner(registry, loaded.nextCommitQC(), utils.Some(loaded)) require.NoError(t, err) - require.Equal(t, wantAppFirst, inner.appVotes.first, + require.Equal(t, wantAppFirst, inner.app.votes.first, "appVotes must follow prune-anchor GlobalRange, not registry.FirstBlock") - require.NotEqual(t, registry.FirstBlock(), inner.appVotes.first) + require.NotEqual(t, registry.FirstBlock(), inner.app.votes.first) } func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { @@ -899,7 +899,7 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { // All current lanes are present after construction. for lane := range duo.Current.Committee().Lanes().All() { - require.Contains(t, i.lanes, lane, "lane %v missing after newInner", lane) + require.Contains(t, i.lanes.byID, lane, "lane %v missing after newInner", lane) } // Add a lane not in the duo — until lane-expiry lands, advance must not @@ -911,11 +911,11 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { } bogusSK := types.GenSecretKey(rng) bogusLane := bogusSK.Public() - i.lanes[bogusLane] = newLaneState() + i.lanes.byID[bogusLane] = newLaneState() 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") + require.Contains(t, i.lanes.byID, bogusLane, "old lanes must be retained until lane-expiry") + require.Contains(t, i.lanes.byID, realLane, "active lane removed incorrectly") } func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { @@ -929,15 +929,15 @@ func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { // Collect a lane from Current (will become Prev after advance). var prevLane types.LaneID - for l := range i.epochDuo.Load().Current.Committee().Lanes().All() { + for l := range i.epoch.duo.Load().Current.Committee().Lanes().All() { prevLane = l break } - require.Contains(t, i.lanes, prevLane, "Current lane missing before reweight") + require.Contains(t, i.lanes.byID, prevLane, "Current lane missing before reweight") duo1 := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(1))) i.advanceEpoch(duo1) // Prior Current lane is now in Prev — must be retained for boundary QC collection. - require.Contains(t, i.lanes, prevLane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") + require.Contains(t, i.lanes.byID, prevLane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") } diff --git a/sei-tendermint/internal/autobahn/avail/lane.go b/sei-tendermint/internal/autobahn/avail/lane.go index d6c5322ad4..620a23cc2a 100644 --- a/sei-tendermint/internal/autobahn/avail/lane.go +++ b/sei-tendermint/internal/autobahn/avail/lane.go @@ -12,7 +12,7 @@ import ( // 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 ls, ok := inner.lanes[lane]; ok { + if ls, ok := inner.lanes.get(lane); ok { return ls.blocks.next } } @@ -24,7 +24,7 @@ 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() { - ls, ok := inner.lanes[lane] + ls, ok := inner.lanes.get(lane) if !ok { return nil, ErrBadLane } @@ -59,13 +59,13 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos return fmt.Errorf("block.VerifySig(): %w", err) } for inner, ctrl := range s.inner.Lock() { - ls, ok := inner.lanes[h.Lane()] + ls, ok := inner.lanes.get(h.Lane()) if !ok { return ErrBadLane } q := ls.blocks if err := ctrl.WaitUntil(ctx, func() bool { - return h.BlockNumber() <= min(q.next, ls.persistedBlockStart+BlocksPerLane-1) + return h.BlockNumber() <= min(q.next, ls.durable.admitLimit()-1) }); err != nil { return err } @@ -113,12 +113,12 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote var verifiedEpoch types.EpochIndex for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { - c := inner.epochDuo.Load().Current.Committee() + c := inner.epoch.duo.Load().Current.Committee() return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) }); err != nil { return err } - duo := inner.epochDuo.Load() + duo := inner.epoch.duo.Load() committee = duo.Current.Committee() verifiedEpoch = duo.Current.EpochIndex() } @@ -129,18 +129,18 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return fmt.Errorf("vote.Verify(): %w", err) } for inner, ctrl := range s.inner.Lock() { - ls, ok := inner.lanes[h.Lane()] + ls, ok := inner.lanes.get(h.Lane()) if !ok { return ErrBadLane } q := ls.votes if err := ctrl.WaitUntil(ctx, func() bool { - return h.BlockNumber() < ls.persistedBlockStart+BlocksPerLane + return h.BlockNumber() < ls.durable.admitLimit() }); err != nil { return err } // WaitUntil may release the lock; re-check membership under live Current. - live := inner.epochDuo.Load() + live := inner.epoch.duo.Load() if live.Current.EpochIndex() != verifiedEpoch && (live.Current.Committee().Weight(vote.Key()) == 0 || !live.Current.Committee().HasLane(h.Lane())) { @@ -168,7 +168,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() { - ls, ok := inner.lanes[lr.Lane()] + ls, ok := inner.lanes.get(lr.Lane()) if !ok { return nil, types.ErrPruned } @@ -201,12 +201,12 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc 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] + ls, ok := inner.lanes.get(lane) if !ok { return ErrBadLane } if err := ctrl.WaitUntil(ctx, func() bool { - return toProduce < ls.persistedBlockStart+BlocksPerLane + return toProduce < ls.durable.admitLimit() }); err != nil { return err } @@ -223,11 +223,11 @@ func (s *State) WaitForLaneQCs( for inner, ctrl := range s.inner.Lock() { laneQCs := map[types.LaneID]*types.LaneQC{} for { - ep := inner.epochDuo.Load().Current + ep := inner.epoch.duo.Load().Current 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).Get(); ok { + if qc, ok := inner.lanes.laneQC(lane, first+i).Get(); ok { laneQCs[lane] = qc } else { break @@ -256,12 +256,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() { - ls, ok := inner.lanes[lane] + ls, ok := inner.lanes.get(lane) if !ok { return nil, ErrBadLane } q := ls.blocks - if n >= ls.persistedBlockStart+BlocksPerLane { + if n >= ls.durable.admitLimit() { return nil, fmt.Errorf("lane full") } if q.next != n { diff --git a/sei-tendermint/internal/autobahn/avail/lane_collection.go b/sei-tendermint/internal/autobahn/avail/lane_collection.go new file mode 100644 index 0000000000..f5a7e4220b --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/lane_collection.go @@ -0,0 +1,93 @@ +package avail + +import ( + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// laneCollection owns per-lane block/vote pipelines and their durable ranges. +type laneCollection struct { + byID map[types.LaneID]*laneState +} + +type laneState struct { + blocks *queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] + votes *queue[types.BlockNumber, *blockVotes] + durable laneDurability +} + +// laneDurability tracks the durable prune floor and write tip for one lane. +// Invariant: persistedBlockFirst ≤ blocks.first ≤ persistedBlockNext ≤ blocks.next +// (blocks.first/next live on laneState; this type owns only the durable cursors). +type laneDurability struct { + // persistedBlockNext tracks 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). + // + // 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. + persistedBlockNext types.BlockNumber + // persistedBlockFirst is the block number derived from the last durably + // persisted prune anchor for this lane. Block admission (PushBlock, + // ProduceBlock, WaitForCapacity, PushVote) uses persistedBlockFirst + + // BlocksPerLane as the capacity limit, ensuring we never admit more blocks + // than can be recovered after a crash. + persistedBlockFirst types.BlockNumber +} + +func newLaneState() *laneState { + return &laneState{ + blocks: newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]](), + votes: newQueue[types.BlockNumber, *blockVotes](), + } +} + +func (c *laneCollection) get(lane types.LaneID) (*laneState, bool) { + ls, ok := c.byID[lane] + return ls, ok +} + +func (c *laneCollection) getOrInsert(lane types.LaneID) *laneState { + if ls, ok := c.byID[lane]; ok { + return ls + } + ls := newLaneState() + c.byID[lane] = ls + return ls +} + +func (c *laneCollection) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { + bv, ok := c.byID[lane].votes.q[n] + if !ok { + return utils.None[*types.LaneQC]() + } + return bv.laneQC() +} + +func (d *laneDurability) admitLimit() types.BlockNumber { + return d.persistedBlockFirst + BlocksPerLane +} + +func (d *laneDurability) advanceFirst(first types.BlockNumber) bool { + if first > d.persistedBlockFirst { + d.persistedBlockFirst = first + return true + } + return false +} + +func (d *laneDurability) advanceNext(next types.BlockNumber) { + d.persistedBlockNext = next +} + +func (d *laneDurability) floorNext(first types.BlockNumber) { + if d.persistedBlockNext < first { + d.persistedBlockNext = first + } +} diff --git a/sei-tendermint/internal/autobahn/avail/persistence.go b/sei-tendermint/internal/autobahn/avail/persistence.go index 7eeadf57c3..939a948989 100644 --- a/sei-tendermint/internal/autobahn/avail/persistence.go +++ b/sei-tendermint/internal/autobahn/avail/persistence.go @@ -129,9 +129,9 @@ func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailStat // TODO: use a single WAL for anchor and CommitQCs to make // this atomic rather than relying on write order. func (s *State) runPersist(ctx context.Context, pers persisters) error { - var lastPersistedAppQCNext types.RoadIndex + var persistedAnchorNext types.RoadIndex for { - batch, err := s.collectPersistBatch(ctx, lastPersistedAppQCNext) + batch, err := s.collectPersistBatch(ctx, persistedAnchorNext) if err != nil { return err } @@ -145,7 +145,7 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { return fmt.Errorf("persist prune anchor: %w", err) } s.advancePersistedBlockStart(anchor.CommitQC) - lastPersistedAppQCNext = anchor.CommitQC.Proposal().Index() + 1 + persistedAnchorNext = anchor.CommitQC.Proposal().Index() + 1 anchorQC = utils.Some(anchor.CommitQC) } @@ -203,14 +203,11 @@ type persistBatch struct { // advancePersistedBlockStart updates the per-lane block admission boundary // after durably writing the prune anchor. This unblocks PushBlock/ProduceBlock -// waiters that are gated on persistedBlockStart + BlocksPerLane. +// waiters that are gated on persistedBlockFirst + BlocksPerLane. func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { for inner, ctrl := range s.inner.Lock() { - for lane, ls := range inner.lanes { - start := commitQC.LaneRange(lane).First() - if start > ls.persistedBlockStart { - ls.persistedBlockStart = start - } + for lane, ls := range inner.lanes.byID { + ls.durable.advanceFirst(commitQC.LaneRange(lane).First()) } ctrl.Updated() } @@ -222,11 +219,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() { - ls, ok := inner.lanes[lane] + ls, ok := inner.lanes.get(lane) if !ok { return } - ls.nextBlockToPersist = next + ls.durable.advanceNext(next) ctrl.Updated() } } @@ -235,56 +232,56 @@ func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { // gating consensus from advancing until the QC is durable. func (s *State) markCommitQCsPersisted(qc *types.CommitQC) { for inner, ctrl := range s.inner.Lock() { - inner.latestCommitQC.Store(utils.Some(qc)) + inner.commits.markPersisted(qc) ctrl.Updated() } } // collectPersistBatch waits for new blocks or commitQCs and collects them under lock. -func (s *State) collectPersistBatch(ctx context.Context, lastPersistedAppQCNext types.RoadIndex) (persistBatch, error) { +func (s *State) collectPersistBatch(ctx context.Context, persistedAnchorNext types.RoadIndex) (persistBatch, error) { var b persistBatch for inner, ctrl := range s.inner.Lock() { - // Derive the CommitQC persist cursor from latestCommitQC. This is - // safe because latestCommitQC is only advanced by markCommitQCsPersisted + // Derive the CommitQC persist cursor from persistedCommitQC. This is + // safe because persistedCommitQC is only advanced by markCommitQCsPersisted // (after disk write) and on startup (from disk). Applying a prune anchor - // does not update latestCommitQC, so this always reflects persistence - // state. The max clamp with commitQCs.first handles an anchor + // does not update persistedCommitQC, so this always reflects persistence + // state. The max clamp with commits.qcs.first handles an anchor // fast-forwarding the queue past the cursor. - commitQCNext := types.NextIndexOpt(inner.latestCommitQC.Load()) + commitQCNext := types.NextIndexOpt(inner.commits.persistedCommitQC.Load()) if err := ctrl.WaitUntil(ctx, func() bool { - if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { + if types.NextOpt(inner.app.latestAppQC) != persistedAnchorNext { return true } - for _, ls := range inner.lanes { - if ls.nextBlockToPersist < ls.blocks.next { + for _, ls := range inner.lanes.byID { + if ls.durable.persistedBlockNext < ls.blocks.next { return true } } - return commitQCNext < inner.commitQCs.next + return commitQCNext < inner.commits.qcs.next }); err != nil { return b, err } - for _, ls := range inner.lanes { - start := max(ls.nextBlockToPersist, ls.blocks.first) + for _, ls := range inner.lanes.byID { + start := max(ls.durable.persistedBlockNext, 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) - for n := commitQCNext; n < inner.commitQCs.next; n++ { - b.commitQCs = append(b.commitQCs, inner.commitQCs.q[n]) + commitQCNext = max(commitQCNext, inner.commits.qcs.first) + for n := commitQCNext; n < inner.commits.qcs.next; n++ { + b.commitQCs = append(b.commitQCs, inner.commits.qcs.q[n]) } - if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { - if appQC, ok := inner.latestAppQC.Get(); ok { + if types.NextOpt(inner.app.latestAppQC) != persistedAnchorNext { + if appQC, ok := inner.app.latestAppQC.Get(); ok { idx := appQC.Proposal().RoadIndex() - if qc, ok := inner.commitQCs.q[idx]; ok { + if qc, ok := inner.commits.qcs.q[idx]; ok { b.pruneAnchor = utils.Some(&PruneAnchor{ AppQC: appQC, CommitQC: qc, }) // Capture under the same lock as the anchor so an epoch slide // cannot move its committee out of the live duo before I/O. - ep, err := inner.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) + ep, err := inner.epoch.duo.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { return b, fmt.Errorf("EpochForRoad(%d): %w", qc.Proposal().Index(), err) } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index cd766bd841..f7286f5422 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -28,7 +28,7 @@ type State struct { key types.SecretKey data *data.State inner utils.Watch[*inner] - epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epochDuo + epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epoch.duo // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. @@ -65,22 +65,22 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin key: key, data: data, inner: utils.NewWatch(inner), - epochDuo: inner.epochDuo.Subscribe(), + epochDuo: inner.epoch.duo.Subscribe(), persisters: pers, }, nil } func (s *State) FirstCommitQC() types.RoadIndex { for inner := range s.inner.Lock() { - return inner.commitQCs.first + return inner.commits.qcs.first } panic("unreachable") } -// NextCommitQC is the next CommitQC road after restore/admit (commitQCs.next). +// NextCommitQC is the next CommitQC road after restore/admit (commits.qcs.next). func (s *State) NextCommitQC() types.RoadIndex { for inner := range s.inner.Lock() { - return inner.commitQCs.next + return inner.commits.qcs.next } panic("unreachable") } @@ -91,9 +91,10 @@ func (s *State) Data() *data.State { } // LastCommitQC returns receiver of the LastCommitQC. +// The tip is the latest durably persisted CommitQC, not merely the admitted tip. func (s *State) LastCommitQC() utils.AtomicRecv[utils.Option[*types.CommitQC]] { for inner := range s.inner.Lock() { - return inner.latestCommitQC.Subscribe() + return inner.commits.persistedCommitQC.Subscribe() } panic("unreachable") } @@ -130,7 +131,7 @@ func (s *State) Run(ctx context.Context) error { var blocks []*types.Block for inner := range s.inner.Lock() { for _, h := range qc.Headers() { - ls, ok := inner.lanes[h.Lane()] + ls, ok := inner.lanes.get(h.Lane()) if !ok { continue } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index ddc42915f1..287384834e 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -36,7 +36,7 @@ func registerDuoAtEpoch(s *State, n types.EpochIndex) { r.EnsureEpoch(n) duo := utils.OrPanic1(r.DuoAt(epoch.FirstRoad(n))) for inner := range s.inner.Lock() { - inner.epochDuo.Store(duo) + inner.epoch.duo.Store(duo) } } @@ -512,7 +512,7 @@ func TestPushVote_WaitsForFutureEpochSigner(t *testing.T) { require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - ls, ok := inner.lanes[lane] + ls, ok := inner.lanes.byID[lane] require.True(t, ok) require.Contains(t, ls.votes.q[0].byKey, futureKey.Public()) } @@ -550,7 +550,7 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { ep0 := state.epochDuo.Load().Current lane := keys[0].Public() - n := types.BlockNumber(BlocksPerLane) // WaitUntil: n >= persistedBlockStart+BlocksPerLane + n := types.BlockNumber(BlocksPerLane) // WaitUntil: n >= persistedBlockFirst+BlocksPerLane header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() vote := types.Sign(keys[0], types.NewLaneVote(header)) @@ -567,15 +567,15 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { for inner, ctrl := range state.inner.Lock() { inner.advanceEpoch(duo1) - inner.lanes[lane].persistedBlockStart = 1 // unblock: n < 1+BlocksPerLane + inner.lanes.byID[lane].durable.persistedBlockFirst = 1 // unblock: n < 1+BlocksPerLane ctrl.Updated() } synctest.Wait() require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - require.Equal(t, types.EpochIndex(1), inner.epochDuo.Load().Current.EpochIndex()) - require.Equal(t, types.BlockNumber(0), inner.lanes[lane].votes.next, + require.Equal(t, types.EpochIndex(1), inner.epoch.duo.Load().Current.EpochIndex()) + require.Equal(t, types.BlockNumber(0), inner.lanes.byID[lane].votes.next, "dropped vote must not extend the queue") } }) @@ -611,14 +611,14 @@ func TestPushVote_DropsLaneAfterEpochAdvance(t *testing.T) { for inner, ctrl := range state.inner.Lock() { inner.advanceEpoch(duo1) - inner.lanes[lane].persistedBlockStart = 1 + inner.lanes.byID[lane].durable.persistedBlockFirst = 1 ctrl.Updated() } synctest.Wait() require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - require.Equal(t, types.BlockNumber(0), inner.lanes[lane].votes.next, + require.Equal(t, types.BlockNumber(0), inner.lanes.byID[lane].votes.next, "dropped vote must not extend the queue") } }) @@ -650,14 +650,14 @@ func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { for inner, ctrl := range state.inner.Lock() { inner.advanceEpoch(duo1) - inner.lanes[lane].persistedBlockStart = 1 + inner.lanes.byID[lane].durable.persistedBlockFirst = 1 ctrl.Updated() } synctest.Wait() require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - ls := inner.lanes[lane] + ls := inner.lanes.byID[lane] require.Contains(t, ls.votes.q[n].byKey, keys[0].Public()) require.Equal(t, uint64(1000), ls.votes.q[n].byHash[header.Hash()].weight) } @@ -1235,9 +1235,9 @@ func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { // Plant an admitted QC, then slide the duo past it (skip live PushCommitQC). for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.FirstRoad(m) - inner.commitQCs.next = epoch.FirstRoad(m) + 1 - inner.commitQCs.q[epoch.FirstRoad(m)] = qc + inner.commits.qcs.first = epoch.FirstRoad(m) + inner.commits.qcs.next = epoch.FirstRoad(m) + 1 + inner.commits.qcs.q[epoch.FirstRoad(m)] = qc } state.markCommitQCsPersisted(qc) registerDuoAtEpoch(state, m+2) @@ -1271,9 +1271,9 @@ func TestFullCommitQCAfterWindowHardFails(t *testing.T) { require.Equal(t, epoch.FirstRoad(m), qc1.Proposal().Index()) for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.FirstRoad(m) - inner.commitQCs.next = epoch.FirstRoad(m) + 1 - inner.commitQCs.q[epoch.FirstRoad(m)] = qc1 + inner.commits.qcs.first = epoch.FirstRoad(m) + inner.commits.qcs.next = epoch.FirstRoad(m) + 1 + inner.commits.qcs.q[epoch.FirstRoad(m)] = qc1 } state.markCommitQCsPersisted(qc1) @@ -1308,14 +1308,14 @@ func TestPushCommitQCMidEpochNoExecLeash(t *testing.T) { require.Equal(t, epoch.FirstRoad(m), qc1.Proposal().Index()) for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.FirstRoad(m) - inner.commitQCs.next = epoch.FirstRoad(m) + inner.commits.qcs.first = epoch.FirstRoad(m) + inner.commits.qcs.next = epoch.FirstRoad(m) } state.markCommitQCsPersisted(prevTip) require.NoError(t, state.PushCommitQC(t.Context(), qc1)) for inner := range state.inner.Lock() { - require.Equal(t, epoch.FirstRoad(m)+1, inner.commitQCs.next) + require.Equal(t, epoch.FirstRoad(m)+1, inner.commits.qcs.next) } } @@ -1356,9 +1356,9 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCM) - inner.commitQCs.first = epoch.LastRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) + inner.app.latestAppQC = utils.Some(appQCM) + inner.commits.qcs.first = epoch.LastRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) @@ -1366,14 +1366,14 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() synctest.Wait() // parked on WaitForDuo(M+1) for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m), inner.commitQCs.next, "not admitted until exec leash") + require.Equal(t, epoch.LastRoad(m), inner.commits.qcs.next, "not admitted until exec leash") } registry.EnsureEpoch(m + 1) synctest.Wait() require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) } }) } @@ -1407,8 +1407,8 @@ func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.LastRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) + inner.commits.qcs.first = epoch.LastRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) @@ -1416,14 +1416,14 @@ func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { go func() { errCh <- state.PushAppQC(ctx, appQCLast, qcLast) }() synctest.Wait() for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m), inner.commitQCs.next, "not admitted until exec leash") + require.Equal(t, epoch.LastRoad(m), inner.commits.qcs.next, "not admitted until exec leash") } registry.EnsureEpoch(m + 1) synctest.Wait() require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) } }) } @@ -1464,9 +1464,9 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCPrev) // only M-1 — not enough to close M - inner.commitQCs.first = epoch.LastRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) + inner.app.latestAppQC = utils.Some(appQCPrev) // only M-1 — not enough to close M + inner.commits.qcs.first = epoch.LastRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) @@ -1474,7 +1474,7 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() synctest.Wait() for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m), inner.commitQCs.next, "not admitted without AppQC in M") + require.Equal(t, epoch.LastRoad(m), inner.commits.qcs.next, "not admitted without AppQC in M") } qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ @@ -1486,13 +1486,13 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.EpochIndex()))) for inner, ctrl := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCM) + inner.app.latestAppQC = utils.Some(appQCM) ctrl.Updated() } synctest.Wait() require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) + require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) } }) } @@ -1521,8 +1521,8 @@ func TestPushCommitQCEpoch0SealWaitsForAppQC(t *testing.T) { require.Equal(t, epoch.LastRoad(0), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { - inner.commitQCs.first = epoch.LastRoad(0) - inner.commitQCs.next = epoch.LastRoad(0) + inner.commits.qcs.first = epoch.LastRoad(0) + inner.commits.qcs.next = epoch.LastRoad(0) } state.markCommitQCsPersisted(prevOnLast) @@ -1530,19 +1530,19 @@ func TestPushCommitQCEpoch0SealWaitsForAppQC(t *testing.T) { go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() synctest.Wait() for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(0), inner.commitQCs.next) + require.Equal(t, epoch.LastRoad(0), inner.commits.qcs.next) } appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( 0, 0, types.GenAppHash(rng), 0))) for inner, ctrl := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQC0) + inner.app.latestAppQC = utils.Some(appQC0) ctrl.Updated() } synctest.Wait() require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(0)+1, inner.commitQCs.next) + require.Equal(t, epoch.LastRoad(0)+1, inner.commits.qcs.next) } }) } @@ -1582,16 +1582,16 @@ func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.EpochIndex()))) for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCPrev) // stale; PushAppQC prune installs appQCLast - inner.commitQCs.first = epoch.LastRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) + inner.app.latestAppQC = utils.Some(appQCPrev) // stale; PushAppQC prune installs appQCLast + inner.commits.qcs.first = epoch.LastRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commitQCs.next) - require.Equal(t, m, inner.epochDuo.Load().Current.EpochIndex()) + require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) + require.Equal(t, m, inner.epoch.duo.Load().Current.EpochIndex()) } advanceUntilCurrent(t, state, m+1) } @@ -1632,9 +1632,9 @@ func TestEpochAdvanceGapHandoff(t *testing.T) { require.Equal(t, epoch.FirstRoad(m+1), qcNext.Proposal().Index()) for inner := range state.inner.Lock() { - inner.latestAppQC = utils.Some(appQCM) - inner.commitQCs.first = epoch.FirstRoad(m) - inner.commitQCs.next = epoch.LastRoad(m) + inner.app.latestAppQC = utils.Some(appQCM) + inner.commits.qcs.first = epoch.FirstRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) diff --git a/sei-tendermint/internal/autobahn/avail/subscriptions.go b/sei-tendermint/internal/autobahn/avail/subscriptions.go index 7a98861118..74f44fa5fe 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions.go @@ -46,8 +46,8 @@ 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, ls := range inner.lanes { - upperBound := min(ls.blocks.next, ls.nextBlockToPersist) + for lane, ls := range inner.lanes.byID { + upperBound := min(ls.blocks.next, ls.durable.persistedBlockNext) for i := max(ls.blocks.first, r.next[lane]); i < upperBound; i++ { batch = append(batch, ls.blocks.q[i].Msg().Block().Header()) } diff --git a/sei-tendermint/internal/p2p/giga_router_common_test.go b/sei-tendermint/internal/p2p/giga_router_common_test.go index 4e340a157c..a9b2ea38a7 100644 --- a/sei-tendermint/internal/p2p/giga_router_common_test.go +++ b/sei-tendermint/internal/p2p/giga_router_common_test.go @@ -94,7 +94,8 @@ func TestBuildDataStateStartsRecoveryAtAppTip(t *testing.T) { require.NoError(t, err) registry, err := epoch.NewRegistry(committee, atypes.GlobalBlockNumber(genDoc.InitialHeight), genDoc.GenesisTime) require.NoError(t, err) - qc, blocks := data.TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*atypes.CommitQC]()) + qc0, _ := data.TestCommitQC(rng, registry.EpochAtTip(utils.None[*atypes.CommitQC]()), keys, utils.None[*atypes.CommitQC]()) + qc, blocks := data.TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc0.QC())), keys, utils.Some(qc0.QC())) gr := qc.QC().GlobalRange() require.Greater(t, gr.Len(), 2) last := gr.First + atypes.GlobalBlockNumber(gr.Len()/2) From a13b12089372cea41ae3599142cc8e6740d10107 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 30 Jul 2026 16:17:24 -0700 Subject: [PATCH 82/85] Simplify single-field progress wrappers Use aliases for epoch and registry state where wrapper structs no longer provide additional ownership or behavior. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/app.go | 6 +++++ .../internal/autobahn/avail/app_progress.go | 12 ---------- .../internal/autobahn/avail/epoch_progress.go | 11 --------- .../autobahn/avail/epoch_transition.go | 5 +++- .../internal/autobahn/avail/inner.go | 8 +++---- .../internal/autobahn/avail/inner_test.go | 2 +- .../internal/autobahn/avail/lane.go | 8 +++---- .../internal/autobahn/avail/persistence.go | 2 +- .../internal/autobahn/avail/state.go | 4 ++-- .../internal/autobahn/avail/state_test.go | 6 ++--- .../internal/autobahn/epoch/registry.go | 24 ++++++++----------- .../internal/autobahn/epoch/registry_test.go | 8 ++----- .../internal/autobahn/epoch/testonly.go | 2 +- 13 files changed, 37 insertions(+), 61 deletions(-) delete mode 100644 sei-tendermint/internal/autobahn/avail/app_progress.go delete mode 100644 sei-tendermint/internal/autobahn/avail/epoch_progress.go diff --git a/sei-tendermint/internal/autobahn/avail/app.go b/sei-tendermint/internal/autobahn/avail/app.go index 74fca5015c..65dc14178b 100644 --- a/sei-tendermint/internal/autobahn/avail/app.go +++ b/sei-tendermint/internal/autobahn/avail/app.go @@ -8,6 +8,12 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// appProgress owns the in-memory AppQC tip and AppVote accumulators. +type appProgress struct { + latestAppQC utils.Option[*types.AppQC] + votes *queue[types.GlobalBlockNumber, appVotes] +} + // LastAppQC returns the latest observed AppQC. func (s *State) LastAppQC() utils.Option[*types.AppQC] { for inner := range s.inner.Lock() { diff --git a/sei-tendermint/internal/autobahn/avail/app_progress.go b/sei-tendermint/internal/autobahn/avail/app_progress.go deleted file mode 100644 index dba3f760ce..0000000000 --- a/sei-tendermint/internal/autobahn/avail/app_progress.go +++ /dev/null @@ -1,12 +0,0 @@ -package avail - -import ( - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -// appProgress owns the in-memory AppQC tip and AppVote accumulators. -type appProgress struct { - latestAppQC utils.Option[*types.AppQC] - votes *queue[types.GlobalBlockNumber, appVotes] -} diff --git a/sei-tendermint/internal/autobahn/avail/epoch_progress.go b/sei-tendermint/internal/autobahn/avail/epoch_progress.go deleted file mode 100644 index d451989fd0..0000000000 --- a/sei-tendermint/internal/autobahn/avail/epoch_progress.go +++ /dev/null @@ -1,11 +0,0 @@ -package avail - -import ( - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -// epochProgress owns the active Prev|Current admission window. -type epochProgress struct { - duo utils.AtomicSend[types.EpochDuo] // Store under Lock; State holds Recv -} diff --git a/sei-tendermint/internal/autobahn/avail/epoch_transition.go b/sei-tendermint/internal/autobahn/avail/epoch_transition.go index 7bc4779a65..9c66517089 100644 --- a/sei-tendermint/internal/autobahn/avail/epoch_transition.go +++ b/sei-tendermint/internal/autobahn/avail/epoch_transition.go @@ -10,6 +10,9 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// epochProgress is the active Prev|Current admission window. +type epochProgress = utils.AtomicSend[types.EpochDuo] + func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { // Debug: Info is too chatty at epoch boundaries (many peers a road behind). logger.Debug("dropping stale "+what+": road behind window", @@ -168,7 +171,7 @@ func (s *State) runAdvanceEpoch(ctx context.Context) error { } for inner, ctrl := range s.inner.Lock() { - live := inner.epoch.duo.Load() + live := inner.epoch.Load() if live.Current.EpochIndex() != epochIdx { break } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index f20f7a83cf..2562b4897e 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -64,9 +64,7 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. } i := &inner{ - epoch: epochProgress{ - duo: utils.NewAtomicSend(startEpochDuo), - }, + epoch: utils.NewAtomicSend(startEpochDuo), app: appProgress{ latestAppQC: utils.None[*types.AppQC](), votes: newQueue[types.GlobalBlockNumber, appVotes](), @@ -174,7 +172,7 @@ func verifyCommitQCInDuo(duo types.EpochDuo, qc *types.CommitQC) error { } // advanceEpoch installs nextDuo at a boundary. Sole post-construction writer of -// epoch.duo (via runAdvanceEpoch). Caller must ensure nextDuo is the next epoch +// epoch (via runAdvanceEpoch). Caller must ensure nextDuo is the next epoch // after Current and that seal leashes (waitForAppQC, registry WaitForDuo) are // already satisfied. Adds Current lanes; does not delete old lanes // (TODO(lane-expiry)). Touches epoch + lane votes (reweight). @@ -188,7 +186,7 @@ func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { ls.votes.q[n].reweight(current) } } - i.epoch.duo.Store(nextDuo) + i.epoch.Store(nextDuo) } // pushPruneAnchor advances queue boundaries for an AppQC and its matching diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 12857731cf..d362c31ce4 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -929,7 +929,7 @@ func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { // Collect a lane from Current (will become Prev after advance). var prevLane types.LaneID - for l := range i.epoch.duo.Load().Current.Committee().Lanes().All() { + for l := range i.epoch.Load().Current.Committee().Lanes().All() { prevLane = l break } diff --git a/sei-tendermint/internal/autobahn/avail/lane.go b/sei-tendermint/internal/autobahn/avail/lane.go index 620a23cc2a..8dbc654c65 100644 --- a/sei-tendermint/internal/autobahn/avail/lane.go +++ b/sei-tendermint/internal/autobahn/avail/lane.go @@ -113,12 +113,12 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote var verifiedEpoch types.EpochIndex for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { - c := inner.epoch.duo.Load().Current.Committee() + c := inner.epoch.Load().Current.Committee() return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) }); err != nil { return err } - duo := inner.epoch.duo.Load() + duo := inner.epoch.Load() committee = duo.Current.Committee() verifiedEpoch = duo.Current.EpochIndex() } @@ -140,7 +140,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return err } // WaitUntil may release the lock; re-check membership under live Current. - live := inner.epoch.duo.Load() + live := inner.epoch.Load() if live.Current.EpochIndex() != verifiedEpoch && (live.Current.Committee().Weight(vote.Key()) == 0 || !live.Current.Committee().HasLane(h.Lane())) { @@ -223,7 +223,7 @@ func (s *State) WaitForLaneQCs( for inner, ctrl := range s.inner.Lock() { laneQCs := map[types.LaneID]*types.LaneQC{} for { - ep := inner.epoch.duo.Load().Current + ep := inner.epoch.Load().Current for lane := range ep.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { diff --git a/sei-tendermint/internal/autobahn/avail/persistence.go b/sei-tendermint/internal/autobahn/avail/persistence.go index 939a948989..f3d49aee7a 100644 --- a/sei-tendermint/internal/autobahn/avail/persistence.go +++ b/sei-tendermint/internal/autobahn/avail/persistence.go @@ -281,7 +281,7 @@ func (s *State) collectPersistBatch(ctx context.Context, persistedAnchorNext typ }) // Capture under the same lock as the anchor so an epoch slide // cannot move its committee out of the live duo before I/O. - ep, err := inner.epoch.duo.Load().EpochForRoad(qc.Proposal().Index()) + ep, err := inner.epoch.Load().EpochForRoad(qc.Proposal().Index()) if err != nil { return b, fmt.Errorf("EpochForRoad(%d): %w", qc.Proposal().Index(), err) } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index f7286f5422..ffdcbd04ba 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -28,7 +28,7 @@ type State struct { key types.SecretKey data *data.State inner utils.Watch[*inner] - epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epoch.duo + epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epoch // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. @@ -65,7 +65,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin key: key, data: data, inner: utils.NewWatch(inner), - epochDuo: inner.epoch.duo.Subscribe(), + epochDuo: inner.epoch.Subscribe(), persisters: pers, }, nil } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 287384834e..4ed98ad45d 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -36,7 +36,7 @@ func registerDuoAtEpoch(s *State, n types.EpochIndex) { r.EnsureEpoch(n) duo := utils.OrPanic1(r.DuoAt(epoch.FirstRoad(n))) for inner := range s.inner.Lock() { - inner.epoch.duo.Store(duo) + inner.epoch.Store(duo) } } @@ -574,7 +574,7 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - require.Equal(t, types.EpochIndex(1), inner.epoch.duo.Load().Current.EpochIndex()) + require.Equal(t, types.EpochIndex(1), inner.epoch.Load().Current.EpochIndex()) require.Equal(t, types.BlockNumber(0), inner.lanes.byID[lane].votes.next, "dropped vote must not extend the queue") } @@ -1591,7 +1591,7 @@ func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) for inner := range state.inner.Lock() { require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) - require.Equal(t, m, inner.epoch.duo.Load().Current.EpochIndex()) + require.Equal(t, m, inner.epoch.Load().Current.EpochIndex()) } advanceUntilCurrent(t, state, m+1) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index adc38963f4..42f0aaf475 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -27,9 +27,7 @@ func LastRoad(idx types.EpochIndex) types.RoadIndex { return FirstRoad(idx+1) - 1 } -type registryState struct { - m map[types.EpochIndex]*types.Epoch -} +type registryState = map[types.EpochIndex]*types.Epoch // Registry is the authoritative store of epoch/committee metadata for all // layers (consensus, data, avail). @@ -57,7 +55,7 @@ type registryState struct { // // TODO(autobahn): replace genesis placeholders with epoch info on blocks. type Registry struct { - state utils.RWMutex[*registryState] + state utils.RWMutex[registryState] // epochGen bumps on every new registration; WaitForDuo waits on it so // filling a gap still wakes waiters. epochGen utils.AtomicSend[uint64] @@ -76,9 +74,7 @@ func NewRegistry( ) (*Registry, error) { ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) return &Registry{ - state: utils.NewRWMutex(®istryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep}, - }), + state: utils.NewRWMutex(registryState{0: ep}), epochGen: utils.NewAtomicSend(uint64(0)), genesisFirstBlock: firstBlock, genesisTimestamp: genesisTimestamp, @@ -107,7 +103,7 @@ func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) erro r.EnsureDuoAt(span.First) for s := range r.state.Lock() { for idx := windowFirst; idx <= windowLast; idx++ { - if _, ok := s.m[idx]; ok { + if _, ok := s[idx]; ok { continue } r.makeEpoch(s, idx) @@ -144,7 +140,7 @@ func (r *Registry) FirstTimestamp() time.Time { func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { epochIdx := IndexForRoad(roadIndex) for s := range r.state.RLock() { - if ep, ok := s.m[epochIdx]; ok { + if ep, ok := s[epochIdx]; ok { return ep, nil } return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) @@ -154,13 +150,13 @@ func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { // makeEpoch inserts a genesis-committee placeholder at epochIdx. // Caller holds the write lock. Overwrites if present. Panics without epoch 0. -func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types.Epoch { - if _, ok := s.m[0]; !ok { +func (r *Registry) makeEpoch(s registryState, epochIdx types.EpochIndex) *types.Epoch { + if _, ok := s[0]; !ok { panic("genesis epoch missing from registry") } firstRoad := FirstRoad(epochIdx) epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)}, r.genesisCommittee) - s.m[epochIdx] = epoch + s[epochIdx] = epoch r.epochGen.Store(r.epochGen.Load() + 1) return epoch } @@ -168,12 +164,12 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types // 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[idx]; ok { + if _, ok := s[idx]; ok { return } } for s := range r.state.Lock() { - if _, ok := s.m[idx]; !ok { + if _, ok := s[idx]; !ok { r.makeEpoch(s, idx) } } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index b758b642c8..caca180b01 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -187,9 +187,7 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { })) ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) bare := &Registry{ - state: utils.NewRWMutex(®istryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep}, - }), + state: utils.NewRWMutex(registryState{0: ep}), epochGen: utils.NewAtomicSend(uint64(0)), } _, err := bare.DuoAt(FirstRoad(1)) @@ -206,9 +204,7 @@ func TestDuoAt_ErrorWhenPrevMissing(t *testing.T) { ep2 := types.NewEpoch(2, types.RoadRange{First: FirstRoad(2), Next: FirstRoad(3)}, committee) // Gap: epoch 2 present without epoch 1. bare := &Registry{ - state: utils.NewRWMutex(®istryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep0, 2: ep2}, - }), + state: utils.NewRWMutex(registryState{0: ep0, 2: ep2}), epochGen: utils.NewAtomicSend(uint64(0)), } _, err := bare.DuoAt(FirstRoad(2)) diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 4ed2bdb3ac..4288dca6ed 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -15,7 +15,7 @@ func (r *Registry) LatestEpoch() *types.Epoch { for s := range r.state.RLock() { var best types.EpochIndex var ep *types.Epoch - for idx, e := range s.m { + for idx, e := range s { if ep == nil || idx > best { best = idx ep = e From 7213d04e8d0a4e6ca72f0c0ed88e1f50a4e6a3ee Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 31 Jul 2026 13:50:30 -0700 Subject: [PATCH 83/85] Restore rpc import after main merge The block-sync livelock fix references rpc.Client in the validator dial loop; keep the import after merging onto the newGigaRouterCommon layout. Co-authored-by: Cursor --- sei-tendermint/internal/p2p/giga_router_validator.go | 1 + 1 file changed, 1 insertion(+) diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 2d1d232025..9546ca95d8 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -11,6 +11,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" "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" ) From f2da3d3d1acb04bfa79bd501f3a58f4be3e8c47c Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 31 Jul 2026 19:29:55 -0700 Subject: [PATCH 84/85] refactor(avail): Current tip + AppTip; seal leashes on epoch advance Make avail's live tip Current-only with App lag on AppTip, move seal leashes to runAdvanceEpoch, and treat the registry as a catalog via WaitForEpoch. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch_duo.go | 58 +--- .../autobahn/types/epoch_duo_test.go | 76 ++--- sei-tendermint/internal/autobahn/avail/app.go | 120 ++++---- .../internal/autobahn/avail/block_votes.go | 3 +- .../autobahn/avail/block_votes_test.go | 4 +- .../internal/autobahn/avail/commit.go | 74 ++--- .../internal/autobahn/avail/conv_test.go | 10 +- .../autobahn/avail/epoch_transition.go | 196 +++--------- .../internal/autobahn/avail/inner.go | 115 +++---- .../internal/autobahn/avail/inner_test.go | 83 ++--- .../internal/autobahn/avail/lane.go | 42 ++- .../internal/autobahn/avail/persistence.go | 115 +++---- .../internal/autobahn/avail/state.go | 10 +- .../internal/autobahn/avail/state_test.go | 287 ++++++++++-------- .../internal/autobahn/data/state.go | 8 +- .../internal/autobahn/epoch/registry.go | 90 +++--- .../internal/autobahn/epoch/registry_test.go | 6 +- .../internal/autobahn/epoch/testonly.go | 2 +- 18 files changed, 585 insertions(+), 714 deletions(-) diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go index 95b07ae467..3a6dc3ecf6 100644 --- a/sei-tendermint/autobahn/types/epoch_duo.go +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -7,15 +7,18 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// EpochDuo is a Prev|Current epoch window. Current is always set; Prev is -// absent iff Current is epoch 0, otherwise contiguous with Current (index -// Current-1 and Prev.Next == Current.First). +// EpochDuo is a Prev|Current epoch pair. It is a value for verifying a tipcut +// that may carry an AppQC from the previous epoch — not a shared cross-layer +// state machine. Live avail tip state is Current + App prune anchor instead. +// +// Current is always set; Prev is absent iff Current is epoch 0, otherwise +// contiguous with Current (index Current-1 and Prev.Next == Current.First). type EpochDuo struct { Prev utils.Option[*Epoch] Current *Epoch } -// NewEpochDuo builds a Prev|Current window. Panics unless +// NewEpochDuo builds a Prev|Current pair. Panics unless // prev≠None ⇔ current.EpochIndex()>0, and when Prev is present it must be // contiguous with Current. func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { @@ -37,48 +40,11 @@ func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { return EpochDuo{Prev: prev, Current: current} } -var ErrRoadBeforeWindow = errors.New("road before epoch duo window") -var ErrRoadAfterWindow = errors.New("road after epoch duo window") - -// RoadStatus classifies a road relative to a Prev|Current window for admit waits. -type RoadStatus int - -const ( - RoadReady RoadStatus = iota // in the admitted window - RoadStale // behind the window (soft-drop / ErrPruned) - RoadFuture // ahead of the window (backpressure wait) -) - -// RoadStatusCurrent classifies roadIdx against Current only (CommitQC tip). -func (w EpochDuo) RoadStatusCurrent(roadIdx RoadIndex) RoadStatus { - if w.Current.RoadRange().Has(roadIdx) { - return RoadReady - } - if roadIdx < w.Current.RoadRange().First { - return RoadStale - } - return RoadFuture -} - -// RoadStatusDuo classifies roadIdx against Prev|Current (AppVote/AppQC). -func (w EpochDuo) RoadStatusDuo(roadIdx RoadIndex) RoadStatus { - _, err := w.EpochForRoad(roadIdx) - if err == nil { - return RoadReady - } - if errors.Is(err, ErrRoadBeforeWindow) { - return RoadStale - } - return RoadFuture -} - -// EpochForRoad returns the epoch containing roadIdx. -// Window is [Prev.First or Current.First, Current.Next). Outside → -// ErrRoadBeforeWindow / ErrRoadAfterWindow. Under contiguous Prev|Current there -// is no gap, so a miss after the after-window check is always before-window. -func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { +// ByRoad returns the epoch containing roadIdx within this pair. +// Future roads and roads before Prev return an error (ErrPruned when behind). +func (w EpochDuo) ByRoad(roadIdx RoadIndex) (*Epoch, error) { if roadIdx >= w.Current.RoadRange().Next { - return nil, fmt.Errorf("road %d after window %v: %w", roadIdx, w, ErrRoadAfterWindow) + return nil, errors.New("road belongs to future epoch") } if w.Current.RoadRange().Has(roadIdx) { return w.Current, nil @@ -86,7 +52,7 @@ func (w EpochDuo) EpochForRoad(roadIdx RoadIndex) (*Epoch, error) { if prev, ok := w.Prev.Get(); ok && prev.RoadRange().Has(roadIdx) { return prev, nil } - return nil, fmt.Errorf("road %d before window %v: %w", roadIdx, w, ErrRoadBeforeWindow) + return nil, ErrPruned } func (w EpochDuo) String() string { diff --git a/sei-tendermint/autobahn/types/epoch_duo_test.go b/sei-tendermint/autobahn/types/epoch_duo_test.go index 96e2ecbf56..ae6fdf964c 100644 --- a/sei-tendermint/autobahn/types/epoch_duo_test.go +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -70,73 +70,55 @@ func TestNewEpochDuo_PanicsOnPrevCurrentMismatch(t *testing.T) { }) } -func TestEpochForRoad(t *testing.T) { +func TestByRoad(t *testing.T) { rng := utils.TestRng() prev, current := testDuoEpochs(t, rng) weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} committee := utils.OrPanic1(types.NewCommittee(weights)) // Prev absent only for epoch 0. ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 100}, committee) + // Contiguous duo starting past road 0 so a behind-window road is possible. + latePrev := types.NewEpoch(0, types.RoadRange{First: 100, Next: 200}, committee) + lateCurrent := types.NewEpoch(1, types.RoadRange{First: 200, Next: 300}, committee) currentOnly := types.NewEpochDuo(ep0, utils.None[*types.Epoch]()) withPrev := types.NewEpochDuo(current, utils.Some(prev)) + lateDuo := types.NewEpochDuo(lateCurrent, utils.Some(latePrev)) for _, tc := range []struct { - name string - w types.EpochDuo - road types.RoadIndex - want *types.Epoch - err error + name string + w types.EpochDuo + road types.RoadIndex + want *types.Epoch + wantErr error // nil = success; errors.Is match when non-nil + anyErr bool // future roads: non-nil err, not a typed sentinel }{ - {"ep0_hit", currentOnly, 50, ep0, nil}, - {"ep0_after", currentOnly, 100, nil, types.ErrRoadAfterWindow}, - {"prev_hit", withPrev, 50, prev, nil}, - {"duo_current_hit", withPrev, 150, current, nil}, - {"duo_after", withPrev, 200, nil, types.ErrRoadAfterWindow}, + {"ep0_hit", currentOnly, 50, ep0, nil, false}, + {"ep0_after", currentOnly, 100, nil, nil, true}, + {"prev_hit", withPrev, 50, prev, nil, false}, + {"duo_current_hit", withPrev, 150, current, nil, false}, + {"duo_after", withPrev, 200, nil, nil, true}, + {"behind", lateDuo, 50, nil, types.ErrPruned, false}, } { t.Run(tc.name, func(t *testing.T) { - ep, err := tc.w.EpochForRoad(tc.road) - if tc.err != nil { - if !errors.Is(err, tc.err) { - t.Fatalf("EpochForRoad(%d) = %v, want %v", tc.road, err, tc.err) + ep, err := tc.w.ByRoad(tc.road) + if tc.anyErr { + if err == nil { + t.Fatalf("ByRoad(%d) = nil err, want non-nil", tc.road) + } + return + } + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("ByRoad(%d) = %v, want %v", tc.road, err, tc.wantErr) } return } if err != nil { - t.Fatalf("EpochForRoad(%d): %v", tc.road, err) + t.Fatalf("ByRoad(%d): %v", tc.road, err) } if ep != tc.want { - t.Fatalf("EpochForRoad(%d) = %v, want %v", tc.road, ep, tc.want) - } - }) - } -} - -func TestEpochDuo_RoadStatus(t *testing.T) { - rng := utils.TestRng() - prev, current := testDuoEpochs(t, rng) - withPrev := types.NewEpochDuo(current, utils.Some(prev)) - ep0Only := types.NewEpochDuo(prev, utils.None[*types.Epoch]()) - - for _, tc := range []struct { - name string - w types.EpochDuo - road types.RoadIndex - cur types.RoadStatus - duo types.RoadStatus - }{ - {"prev_road", withPrev, 50, types.RoadStale, types.RoadReady}, - {"current_road", withPrev, 150, types.RoadReady, types.RoadReady}, - {"future_road", withPrev, 200, types.RoadFuture, types.RoadFuture}, - {"ep0_ready", ep0Only, 50, types.RoadReady, types.RoadReady}, - {"ep0_future", ep0Only, 100, types.RoadFuture, types.RoadFuture}, - } { - t.Run(tc.name, func(t *testing.T) { - if got := tc.w.RoadStatusCurrent(tc.road); got != tc.cur { - t.Fatalf("RoadStatusCurrent(%d) = %v, want %v", tc.road, got, tc.cur) - } - if got := tc.w.RoadStatusDuo(tc.road); got != tc.duo { - t.Fatalf("RoadStatusDuo(%d) = %v, want %v", tc.road, got, tc.duo) + t.Fatalf("ByRoad(%d) = %v, want %v", tc.road, ep, tc.want) } }) } diff --git a/sei-tendermint/internal/autobahn/avail/app.go b/sei-tendermint/internal/autobahn/avail/app.go index 65dc14178b..bf68ed6725 100644 --- a/sei-tendermint/internal/autobahn/avail/app.go +++ b/sei-tendermint/internal/autobahn/avail/app.go @@ -2,35 +2,50 @@ package avail import ( "context" + "errors" "fmt" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// appProgress owns the in-memory AppQC tip and AppVote accumulators. +// AppTip is avail's live App tip: AppQC + justifying CommitQC + that epoch +// (Prev when App lags Current). App owns and advances this tip; prune/persist +// only snapshots it for the durability watermark. +type AppTip struct { + AppQC *types.AppQC + CommitQC *types.CommitQC + Epoch *types.Epoch +} + +// Next is one past the AppQC road index (types.NextOpt). +func (t *AppTip) Next() types.RoadIndex { return t.AppQC.Next() } + +// appProgress owns the App tip and AppVote accumulators. type appProgress struct { - latestAppQC utils.Option[*types.AppQC] - votes *queue[types.GlobalBlockNumber, appVotes] + tip utils.Option[*AppTip] + votes *queue[types.GlobalBlockNumber, appVotes] } -// LastAppQC returns the latest observed AppQC. +// LastAppQC returns the AppQC peel of the live App tip. func (s *State) LastAppQC() utils.Option[*types.AppQC] { for inner := range s.inner.Lock() { - return inner.app.latestAppQC + if tip, ok := inner.app.tip.Get(); ok { + return utils.Some(tip.AppQC) + } + return utils.None[*types.AppQC]() } panic("unreachable") } -// WaitForAppQC waits until there is an AppQC for the given index or higher. -// Returns this AppQC and the corresponding CommitQC. -// Together they provide enough information to prune the availability state. +// WaitForAppQC waits until the App tip is at idx or higher. +// Returns the tip AppQC and its matching CommitQC. func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, *types.CommitQC, error) { for inner, ctrl := range s.inner.Lock() { for { - if appQC, ok := inner.app.latestAppQC.Get(); ok { - if x := appQC.Proposal().RoadIndex(); x >= idx && inner.commits.qcs.next > x { - return appQC, inner.commits.qcs.q[x], nil + if tip, ok := inner.app.tip.Get(); ok { + if x := tip.AppQC.Proposal().RoadIndex(); x >= idx { + return tip.AppQC, tip.CommitQC, nil } } if err := ctrl.Wait(ctx); err != nil { @@ -42,42 +57,41 @@ func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.A } // PushAppVote pushes an AppVote to the state. -// Same admit-then-verify as PushAppQC: far-future roads park until the duo -// and CommitQC tip catch up (one stream goroutine; does not block others). +// Far-future roads park until CommitQC tip and App admit window catch up; +// behind-window votes soft-drop. 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 tip. if err := s.waitForCommitQC(ctx, idx); err != nil { return err } - // Too-early roads (ahead of Prev|Current) backpressure; too-late are dropped. - admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppVote", idx) + epoch, err := s.waitForAppEpoch(ctx, idx) if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } return err } - duo, ok := admitted.Get() - if !ok { - return nil - } - ep := utils.OrPanic1(duo.EpochForRoad(idx)) - if got, want := v.Msg().Proposal().EpochIndex(), ep.EpochIndex(); got != want { + if got, want := v.Msg().Proposal().EpochIndex(), epoch.EpochIndex(); got != want { return fmt.Errorf("appVote epoch_index %d, want %d", got, want) } - committee := ep.Committee() + qc, err := s.CommitQC(ctx, idx) + if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } + return err + } + if err := v.Msg().Proposal().Verify(qc); err != nil { + return fmt.Errorf("invalid vote: %w", err) + } + committee := epoch.Committee() if err := v.VerifySig(committee); err != nil { return fmt.Errorf("v.VerifySig(): %w", 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.app.latestAppQC) { + if idx < types.NextOpt(inner.app.tip) { return nil } - // Verify the vote against the CommitQC. - qc := inner.commits.qcs.q[idx] - if err := v.Msg().Proposal().Verify(qc); err != nil { - return fmt.Errorf("invalid vote: %w", err) - } - // Push the vote. n := v.Msg().Proposal().GlobalNumber() q := inner.app.votes for q.next <= n { @@ -87,7 +101,7 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if !ok { return nil } - updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: qc}) + updated, err := inner.pushAppTip(&AppTip{AppQC: appQC, CommitQC: qc, Epoch: epoch}) if err != nil { return err } @@ -98,19 +112,15 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] return nil } -// PushAppQC requires a justifying CommitQC. Epoch slide is async in -// runAdvanceEpoch (same as PushCommitQC). Prune before insert so latestAppQC is -// visible before the advance task observes the new tip. -// -// Same admit-then-verify as PushCommitQC. +// PushAppQC requires a justifying CommitQC. +// Admits only in the App window (Current|App-tip/Prev); parks ahead, soft-drops +// behind. Advances the App tip (and thus the prune watermark) up to AppQC. 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.app.latestAppQC) > appQC.Proposal().RoadIndex() { + if types.NextOpt(inner.app.tip) >= appQC.Next() { return nil } } - // Pair consistency only; ahead-of-window still waits in waitForEpochDuo. if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) } @@ -121,37 +131,31 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *typ return fmt.Errorf("appQC GlobalNumber not in commitQC range") } idx := commitQC.Proposal().Index() - admitted, err := s.waitForEpochDuoOrDropStale(ctx, "AppQC", idx) + epoch, err := s.waitForAppEpoch(ctx, idx) if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } return err } - duo, ok := admitted.Get() - if !ok { - return nil + if got, want := appQC.Proposal().EpochIndex(), epoch.EpochIndex(); got != want { + return fmt.Errorf("appQC epoch_index %d, want %d", got, want) } - ep := utils.OrPanic1(duo.EpochForRoad(idx)) - if err := appQC.Verify(ep); err != nil { + if err := appQC.Verify(epoch); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) } - if err := commitQC.Verify(ep); err != nil { + if err := commitQC.Verify(epoch); err != nil { return fmt.Errorf("commitQC.Verify(): %w", err) } - // Seal CommitQC paired with this AppQC: incoming AppQC satisfies the prune - // leash; still wait on the execution leash when idx closes Current. - if duo.Current.RoadRange().Next-1 == idx && ep.EpochIndex() == duo.Current.EpochIndex() { - if err := s.waitSealLeashes(ctx, duo.Current, idx, utils.Some(appQC.Proposal().EpochIndex())); err != nil { - return err - } - } + tip := &AppTip{AppQC: appQC, CommitQC: commitQC, Epoch: epoch} for inner, ctrl := range s.inner.Lock() { - updated, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: commitQC}) + updated, err := inner.pushAppTip(tip) if err != nil { return err } - if !updated { - return nil + if updated { + ctrl.Updated() } - ctrl.Updated() } return nil } diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index d971be183d..cfe8315248 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -71,8 +71,7 @@ func (bv *blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVo // reweight recomputes already-stored votes under new Current after advanceEpoch. // Zero-weight signers are removed from byKey. Callers wake waiters via // ctrl.Updated() after advanceEpoch (not via a return flag). -func (bv *blockVotes) reweight(newEpoch *types.Epoch) { - c := newEpoch.Committee() +func (bv *blockVotes) reweight(c *types.Committee) { clear(bv.byHash) quorum := c.LaneQuorum() 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 index d1f4e679ce..8b62a75a97 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -91,7 +91,7 @@ func TestPushVote_CurrentCommitteeOnly(t *testing.T) { require.Equal(t, qc.Header().Hash(), gotQC.Header().Hash()) // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; A+B still form quorum under new weights. - bv.reweight(ep1) + bv.reweight(ep1.Committee()) require.True(t, bv.laneQC().IsPresent()) require.True(t, bv.byHash[h].laneQC().IsPresent()) require.False(t, bv.pushVote(ep1, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) @@ -149,7 +149,7 @@ func TestPushVote_ReweightAfterAdvance(t *testing.T) { require.True(t, bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).IsPresent()) require.False(t, bv.pushVote(ep0, types.Sign(keyC, types.NewLaneVote(staleHeader))).IsPresent()) - bv.reweight(ep1) + bv.reweight(ep1.Committee()) require.Equal(t, uint64(1), bv.byHash[h].weight) require.Len(t, bv.byHash[h].votes, 1) require.Equal(t, keyA.Public(), bv.byHash[h].votes[0].Key()) diff --git a/sei-tendermint/internal/autobahn/avail/commit.go b/sei-tendermint/internal/autobahn/avail/commit.go index d04df157a7..10c3c1c790 100644 --- a/sei-tendermint/internal/autobahn/avail/commit.go +++ b/sei-tendermint/internal/autobahn/avail/commit.go @@ -17,6 +17,31 @@ func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error return err } +// waitForCommitQCAndEpoch waits for the CommitQC at idx and returns it with the +// epoch needed to verify it: Current when the QC is in Current, otherwise the +// App-tip epoch (Prev). +func (s *State) waitForCommitQCAndEpoch(ctx context.Context, idx types.RoadIndex) (*types.CommitQC, *types.Epoch, error) { + if err := s.waitForCommitQC(ctx, idx); err != nil { + return nil, nil, err + } + for inner := range s.inner.Lock() { + if idx < inner.commits.qcs.first { + return nil, nil, types.ErrPruned + } + qc := inner.commits.qcs.q[idx] + cur := inner.epoch.Load() + if cur.EpochIndex() == qc.Proposal().EpochIndex() { + return qc, cur, nil + } + tip, ok := inner.app.tip.Get() + if !ok || tip.Epoch == nil { + return nil, nil, fmt.Errorf("commitQC epoch %d needs App-tip epoch", qc.Proposal().EpochIndex()) + } + return qc, tip.Epoch, nil + } + panic("unreachable") +} + // CommitQC returns the CommitQC for the given index. func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.CommitQC, error) { if err := s.waitForCommitQC(ctx, idx); err != nil { @@ -32,63 +57,40 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi } // PushCommitQC admits qc for Current only (too early waits; stale drops). -// Epoch slide is async in runAdvanceEpoch (tip may sit at Current.Next while -// Current still N; N+1 CommitQCs park on waitForEpoch until the duo advances). -// -// Seal (last road of Current): prune + execution leashes before admit -// (interlocking doc CommitQC admission). -// -// Admit-then-verify is intentional backpressure for ahead-of-window QCs. +// N+1 CommitQCs park on Epoch until runAdvanceEpoch slides Current. +// Seal leashes (App anchor + registry N+1) gate that advance, not this admit. func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { - idx := qc.Proposal().Index() - if idx > 0 { - if err := s.waitForCommitQC(ctx, idx-1); err != nil { + if i := qc.Proposal().Index(); i > 0 { + if err := s.waitForCommitQC(ctx, i-1); err != nil { return err } } - admitted, err := s.waitForEpochOrDropStale(ctx, "CommitQC", idx) + epoch, err := s.Epoch(ctx, qc.Proposal().EpochIndex()) if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } return err } - duo, ok := admitted.Get() - if !ok { - return nil - } - ep := duo.Current - if err := qc.Verify(ep); err != nil { + if err := qc.Verify(epoch); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } - if err := s.waitSealLeashes(ctx, ep, idx, utils.None[types.EpochIndex]()); err != nil { - return err - } - for inner, ctrl := range s.inner.Lock() { - if !inner.commits.push(qc) { - return nil + if inner.commits.push(qc) { + ctrl.Updated() } - // persistedCommitQC advances only after durable persist (or no-op persister). - ctrl.Updated() - return nil } return nil } // fullCommitQC returns the FullCommitQC for road n. -// ErrRoadBeforeWindow → ErrPruned (export may jump ahead). ErrRoadAfterWindow hard-fails. func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, error) { - qc, err := s.CommitQC(ctx, n) + qc, epoch, err := s.waitForCommitQCAndEpoch(ctx, n) if err != nil { return nil, err } - ep, err := s.epochDuo.Load().EpochForRoad(qc.Proposal().Index()) - if err != nil { - if errors.Is(err, types.ErrRoadBeforeWindow) { - return nil, types.ErrPruned - } - return nil, err - } var commitHeaders []*types.BlockHeader - for lane := range ep.Committee().Lanes().All() { + for lane := range epoch.Committee().Lanes().All() { headers, err := s.headers(ctx, qc.LaneRange(lane)) if err != nil { return nil, err diff --git a/sei-tendermint/internal/autobahn/avail/conv_test.go b/sei-tendermint/internal/autobahn/avail/conv_test.go index 66b3dec3ff..0d7a507cef 100644 --- a/sei-tendermint/internal/autobahn/avail/conv_test.go +++ b/sei-tendermint/internal/autobahn/avail/conv_test.go @@ -10,7 +10,7 @@ import ( "google.golang.org/protobuf/proto" ) -func TestPruneAnchorConv(t *testing.T) { +func TestAppTipConv(t *testing.T) { rng := utils.TestRng() registry, keys, _ := epoch.GenRegistry(rng, 4) @@ -23,9 +23,9 @@ func TestPruneAnchorConv(t *testing.T) { appProposal := types.NewAppProposal(commitQC.GlobalRange().First, commitQC.Proposal().Index(), types.GenAppHash(rng), commitQC.Proposal().EpochIndex()) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - anchor := &PruneAnchor{AppQC: appQC, CommitQC: commitQC} - pb1 := PruneAnchorConv.Encode(anchor) - decoded, err := PruneAnchorConv.Decode(pb1) + anchor := &AppTip{AppQC: appQC, CommitQC: commitQC} + pb1 := AppTipConv.Encode(anchor) + decoded, err := AppTipConv.Decode(pb1) require.NoError(t, err) - require.True(t, proto.Equal(pb1, PruneAnchorConv.Encode(decoded))) + require.True(t, proto.Equal(pb1, AppTipConv.Encode(decoded))) } diff --git a/sei-tendermint/internal/autobahn/avail/epoch_transition.go b/sei-tendermint/internal/autobahn/avail/epoch_transition.go index 9c66517089..3c67834f51 100644 --- a/sei-tendermint/internal/autobahn/avail/epoch_transition.go +++ b/sei-tendermint/internal/autobahn/avail/epoch_transition.go @@ -2,184 +2,78 @@ package avail import ( "context" - "errors" - "fmt" - "log/slog" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -// epochProgress is the active Prev|Current admission window. -type epochProgress = utils.AtomicSend[types.EpochDuo] - -func logStaleRoad(what string, roadIdx types.RoadIndex, duo types.EpochDuo) { - // Debug: Info is too chatty at epoch boundaries (many peers a road behind). - logger.Debug("dropping stale "+what+": road behind window", - slog.Uint64("road", uint64(roadIdx)), "duo", duo.String()) -} - -// waitUntilRoad waits until status is not RoadFuture; RoadStale → ErrPruned -// with the deciding duo still returned for logging. -func (s *State) waitUntilRoad( - ctx context.Context, - roadIdx types.RoadIndex, - status func(types.EpochDuo) types.RoadStatus, -) (types.EpochDuo, error) { - duo, err := s.epochDuo.Wait(ctx, func(duo types.EpochDuo) bool { - return status(duo) != types.RoadFuture +// Epoch waits until avail Current reaches exactly epoch i. +// If Current has already moved past i, returns ErrPruned. +func (s *State) Epoch(ctx context.Context, i types.EpochIndex) (*types.Epoch, error) { + epoch, err := s.epoch.Wait(ctx, func(epoch *types.Epoch) bool { + return i <= epoch.EpochIndex() }) if err != nil { - return types.EpochDuo{}, err + return nil, err } - switch status(duo) { - case types.RoadReady: - return duo, nil - case types.RoadStale: - return duo, types.ErrPruned - default: - // Wait predicate forbids Future; hitting it is an internal bug. - panic(fmt.Sprintf("waitUntilRoad: unexpected RoadFuture for road %d after Wait", roadIdx)) + if epoch.EpochIndex() != i { + return nil, types.ErrPruned } + return epoch, nil } -// waitForEpoch waits until roadIdx is in Current (CommitQC tip). -func (s *State) waitForEpoch(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { - return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { - return d.RoadStatusCurrent(roadIdx) - }) -} - -// waitForEpochDuo waits until roadIdx is in Prev|Current (AppVote/AppQC). -func (s *State) waitForEpochDuo(ctx context.Context, roadIdx types.RoadIndex) (types.EpochDuo, error) { - return s.waitUntilRoad(ctx, roadIdx, func(d types.EpochDuo) types.RoadStatus { - return d.RoadStatusDuo(roadIdx) - }) -} - -// waitForEpochOrDropStale is PushCommitQC admit: wait for Current, soft-drop if stale. -func (s *State) waitForEpochOrDropStale( - ctx context.Context, what string, roadIdx types.RoadIndex, -) (utils.Option[types.EpochDuo], error) { - return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitForEpoch) -} - -// waitForEpochDuoOrDropStale is PushAppVote/PushAppQC admit: wait for Prev|Current, soft-drop if stale. -func (s *State) waitForEpochDuoOrDropStale( - ctx context.Context, what string, roadIdx types.RoadIndex, -) (utils.Option[types.EpochDuo], error) { - return s.waitRoadOrDropStale(ctx, what, roadIdx, s.waitForEpochDuo) -} - -func (s *State) waitRoadOrDropStale( - ctx context.Context, - what string, - roadIdx types.RoadIndex, - wait func(context.Context, types.RoadIndex) (types.EpochDuo, error), -) (utils.Option[types.EpochDuo], error) { - duo, err := wait(ctx, roadIdx) - if err != nil { - if errors.Is(err, types.ErrPruned) { - logStaleRoad(what, roadIdx, duo) - return utils.None[types.EpochDuo](), nil +// waitForAppEpoch waits until roadIdx is in the App admit window: Current, or +// the App-tip epoch when App lags (Prev). Soft-returns ErrPruned when behind +// that window (caller drops). Parks while roadIdx is still ahead of Current. +func (s *State) waitForAppEpoch(ctx context.Context, roadIdx types.RoadIndex) (*types.Epoch, error) { + for { + if _, err := s.epoch.Wait(ctx, func(cur *types.Epoch) bool { + return roadIdx < cur.RoadRange().Next + }); err != nil { + return nil, err } - return utils.None[types.EpochDuo](), err - } - return utils.Some(duo), nil -} - -// waitForAppQC blocks until latest AppQC is from epochIdx or later. -// -// Seal prune leash (interlocking doc): Availability admits the last CommitQC of -// epoch N only after an AppQC for N exists. Also used by runAdvanceEpoch as a -// no-op once admit already waited. Epoch 0 is not special-cased: leaving 0 -// still needs an AppQC anchor for restart (Current>0 requires one). -func (s *State) waitForAppQC(ctx context.Context, epochIdx types.EpochIndex) error { - for inner, ctrl := range s.inner.Lock() { - ready := func() bool { - appQC, ok := inner.app.latestAppQC.Get() - if !ok { - return false + for inner := range s.inner.Lock() { + cur := inner.epoch.Load() + if roadIdx >= cur.RoadRange().Next { + break // Current moved; retry Wait } - return appQC.Proposal().EpochIndex() >= epochIdx - } - if ready() { - return nil - } - attrs := []any{slog.Uint64("want_epoch", uint64(epochIdx))} - if appQC, ok := inner.app.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 sealing epoch", attrs...) - return ctrl.WaitUntil(ctx, ready) - } - panic("unreachable") -} - -// waitSealLeashes enforces the interlocking-doc seal conditions before admitting -// the last CommitQC of ep: AppQC for ep (unless incomingAppEpoch already -// satisfies) and registry WaitForDuo for the next road (execution leash). -func (s *State) waitSealLeashes( - ctx context.Context, - ep *types.Epoch, - idx types.RoadIndex, - incomingAppEpoch utils.Option[types.EpochIndex], -) error { - last := ep.RoadRange().Next - 1 - if idx != last { - return nil - } - if e, ok := incomingAppEpoch.Get(); !ok || e < ep.EpochIndex() { - if err := s.waitForAppQC(ctx, ep.EpochIndex()); err != nil { - return err + if cur.RoadRange().Has(roadIdx) { + return cur, nil + } + if tip, ok := inner.app.tip.Get(); ok && tip.Epoch != nil && tip.Epoch.RoadRange().Has(roadIdx) { + return tip.Epoch, nil + } + return nil, types.ErrPruned } } - if _, err := s.data.Registry().WaitForDuo(ctx, last+1); err != nil { - return fmt.Errorf("WaitForDuo(%d): %w", last+1, err) - } - return nil } -// runAdvanceEpoch is the sole post-construction writer of epochDuo. When -// commitQCs tip passes Current's last road, seal leashes have already been -// satisfied at PushCommitQC/PushAppQC admit; this waits for tip, re-checks -// leashes (no-op if already met), then advances. N+1 CommitQCs park on -// waitForEpoch until the duo slides. +// runAdvanceEpoch is the sole post-construction writer of Current. Seal leashes: +// - prune: App tip epoch >= Current +// - execution: registry has Current+1 +// +// N+1 CommitQCs park on Epoch(N+1) until this advances Current. func (s *State) runAdvanceEpoch(ctx context.Context) error { - for { - duo := s.epochDuo.Load() - epochIdx := duo.Current.EpochIndex() - last := duo.Current.RoadRange().Next - 1 - + return s.epoch.Iter(ctx, func(ctx context.Context, epoch *types.Epoch) error { for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { - return inner.commits.qcs.next > last + if inner.commits.qcs.next < epoch.RoadRange().Next { + return false + } + tip, ok := inner.app.tip.Get() + return ok && tip.Epoch != nil && tip.Epoch.EpochIndex() >= epoch.EpochIndex() }); err != nil { return err } } - - if err := s.waitForAppQC(ctx, epochIdx); err != nil { - return err - } - nextDuo, err := s.data.Registry().WaitForDuo(ctx, last+1) + next, err := s.data.Registry().WaitForEpoch(ctx, epoch.EpochIndex()+1) if err != nil { return err } - for inner, ctrl := range s.inner.Lock() { - live := inner.epoch.Load() - if live.Current.EpochIndex() != epochIdx { - break - } - if inner.commits.qcs.next <= last { - break + if inner.advanceEpoch(next) { + ctrl.Updated() } - inner.advanceEpoch(nextDuo) - ctrl.Updated() } - } + return nil + }) } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 2562b4897e..decf383d2e 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -18,34 +18,33 @@ import ( // MaybePruneAndPersistLane, but the new member must also appear in // inner.lanes before the next persist cycle. type inner struct { - epoch epochProgress + epoch utils.AtomicSend[*types.Epoch] app appProgress commits commitProgress lanes laneCollection } // loadedAvailState holds data loaded from disk on restart. -// pruneAnchor is the decoded prune anchor (if any). -// commitQCs and blocks are pre-filtered: stale entries below the -// anchor have already been removed by loadPersistedState. +// appTip is the decoded App tip watermark (if any); Epoch is filled in newInner. +// commitQCs and blocks are pre-filtered: stale entries below the tip have +// already been removed by loadPersistedState. // commitQCs are sorted by road index; blocks are sorted by number per lane. // newInner requires both to be contiguous and returns an error on gaps. type loadedAvailState struct { - pruneAnchor utils.Option[*PruneAnchor] - commitQCs []persist.LoadedCommitQC - blocks map[types.LaneID][]persist.LoadedBlock + appTip utils.Option[*AppTip] + commitQCs []persist.LoadedCommitQC + blocks map[types.LaneID][]persist.LoadedBlock } // 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. +// one past the last loaded CommitQC, floored by the App tip when the WAL lags. func (ls *loadedAvailState) nextCommitQC() types.RoadIndex { tip := types.RoadIndex(0) 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) + if appTip, ok := ls.appTip.Get(); ok { + tip = max(tip, appTip.CommitQC.Proposal().Index()+1) } return tip } @@ -64,10 +63,10 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. } i := &inner{ - epoch: utils.NewAtomicSend(startEpochDuo), + epoch: utils.NewAtomicSend(startEpochDuo.Current), app: appProgress{ - latestAppQC: utils.None[*types.AppQC](), - votes: newQueue[types.GlobalBlockNumber, appVotes](), + tip: utils.None[*AppTip](), + votes: newQueue[types.GlobalBlockNumber, appVotes](), }, commits: commitProgress{ qcs: newQueue[types.RoadIndex, *types.CommitQC](), @@ -78,34 +77,43 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. l, ok := loaded.Get() if !ok { if startEpochDuo.Current.EpochIndex() > 0 { - return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) + return nil, fmt.Errorf("app tip required for epoch %d", startEpochDuo.Current.EpochIndex()) } i.app.votes.prune(registry.FirstBlock()) return i, nil } - // Apply the persisted prune anchor first. It advances all queue boundaries, - // retains the anchor CommitQC, and sets app.votes.first from that CommitQC. - if anchor, ok := l.pruneAnchor.Get(); ok { - logger.Info("loaded persisted prune anchor", - slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), - slog.Uint64("globalNumber", uint64(anchor.AppQC.Proposal().GlobalNumber())), + // Apply the persisted App tip first. It advances all queue boundaries, + // retains the tip CommitQC, and sets app.votes.first from that CommitQC. + if tip, ok := l.appTip.Get(); ok { + logger.Info("loaded persisted app tip", + slog.Uint64("roadIndex", uint64(tip.AppQC.Proposal().RoadIndex())), + slog.Uint64("globalNumber", uint64(tip.AppQC.Proposal().GlobalNumber())), ) - if err := verifyCommitQCInDuo(startEpochDuo, anchor.CommitQC); err != nil { - return nil, fmt.Errorf("load prune-anchor CommitQC: %w", err) + if err := verifyCommitQCInDuo(startEpochDuo, tip.CommitQC); err != nil { + return nil, fmt.Errorf("load app-tip CommitQC: %w", err) } - if _, err := i.pushPruneAnchor(anchor); err != nil { - return nil, fmt.Errorf("push prune anchor: %w", err) + // Persisted proto does not yet carry Epoch; recover from the restore duo + // so avail stays self-contained after load. + if tip.Epoch == nil { + ep, err := startEpochDuo.ByRoad(tip.CommitQC.Proposal().Index()) + if err != nil { + return nil, fmt.Errorf("load app-tip Epoch: %w", err) + } + tip.Epoch = ep + } + if _, err := i.pushAppTip(tip); err != nil { + return nil, fmt.Errorf("push app tip: %w", err) } for lane, ls := range i.lanes.byID { - ls.durable.persistedBlockFirst = anchor.CommitQC.LaneRange(lane).First() + ls.durable.persistedBlockFirst = tip.CommitQC.LaneRange(lane).First() } } else if startEpochDuo.Current.EpochIndex() == 0 { - // No anchor: floor app votes at genesis (registry), not tip Current — + // No tip: floor app votes at genesis (registry), not tip Current — // live advanceEpoch also leaves app.votes at the genesis floor. i.app.votes.prune(registry.FirstBlock()) } else { - return nil, fmt.Errorf("prune anchor required for epoch %d", startEpochDuo.Current.EpochIndex()) + return nil, fmt.Errorf("app tip required for epoch %d", startEpochDuo.Current.EpochIndex()) } // Restore persisted CommitQCs. The prune anchor may have already pushed the @@ -161,49 +169,54 @@ func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils. // verifyCommitQCInDuo verifies qc against startEpochDuo (Prev|Current at restore). func verifyCommitQCInDuo(duo types.EpochDuo, qc *types.CommitQC) error { - ep, err := duo.EpochForRoad(qc.Proposal().Index()) + ep, err := duo.ByRoad(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 + return qc.Verify(ep) } -// advanceEpoch installs nextDuo at a boundary. Sole post-construction writer of -// epoch (via runAdvanceEpoch). Caller must ensure nextDuo is the next epoch -// after Current and that seal leashes (waitForAppQC, registry WaitForDuo) are -// already satisfied. Adds Current lanes; does not delete old lanes -// (TODO(lane-expiry)). Touches epoch + lane votes (reweight). -func (i *inner) advanceEpoch(nextDuo types.EpochDuo) { - current := nextDuo.Current - for lane := range current.Committee().Lanes().All() { +// advanceEpoch installs Current. Sole post-construction writer is +// runAdvanceEpoch (after commit tip + seal leashes). +// Adds Current lanes; does not delete old lanes (TODO(lane-expiry)). +func (i *inner) advanceEpoch(epoch *types.Epoch) bool { + if i.epoch.Load().EpochIndex() >= epoch.EpochIndex() { + return false + } + c := epoch.Committee() + for lane := range c.Lanes().All() { i.lanes.getOrInsert(lane) } for _, ls := range i.lanes.byID { for n := ls.votes.first; n < ls.votes.next; n++ { - ls.votes.q[n].reweight(current) + ls.votes.q[n].reweight(c) } } - i.epoch.Store(nextDuo) + i.epoch.Store(epoch) + return true } -// pushPruneAnchor advances queue boundaries for an AppQC and its matching -// CommitQC, retaining the CommitQC when it is the next tip. Returns true when -// the anchor advanced, or false when it was stale. +// pushAppTip advances queue boundaries for an App tip (AppQC + matching +// CommitQC + Epoch), retaining the CommitQC when it is the next tip. +// Returns true when the tip advanced, or false when it was stale. // Cross-progress orchestrator: touches app, commits, and lanes. -func (i *inner) pushPruneAnchor(anchor *PruneAnchor) (bool, error) { - appQC := anchor.AppQC - commitQC := anchor.CommitQC +func (i *inner) pushAppTip(tip *AppTip) (bool, error) { + appQC := tip.AppQC + commitQC := tip.CommitQC + if tip.Epoch == nil { + return false, fmt.Errorf("app tip missing Epoch") + } 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()) } - if idx < types.NextOpt(i.app.latestAppQC) { + if got, want := tip.Epoch.EpochIndex(), appQC.Proposal().EpochIndex(); got != want { + return false, fmt.Errorf("app tip epoch %d != appQC epoch %d", got, want) + } + if idx < types.NextOpt(i.app.tip) { return false, nil } - i.app.latestAppQC = utils.Some(appQC) + i.app.tip = utils.Some(tip) metrics.ObserveAppQC(appQC) i.commits.qcs.prune(idx) i.commits.push(commitQC) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index d362c31ce4..e467ccede0 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -56,10 +56,10 @@ func TestPruneMismatchedIndices(t *testing.T) { state, err = NewState(keys[0], ds, utils.Some(t.TempDir())) require.NoError(t, err) for inner := range state.inner.Lock() { - _, err := inner.pushPruneAnchor(&PruneAnchor{AppQC: makeAppQC(qc1, qc0), CommitQC: qc1}) + _, err := inner.pushAppTip(&AppTip{AppQC: makeAppQC(qc1, qc0), CommitQC: qc1, Epoch: ep}) require.Error(t, err, "good range, bad index should fail") - require.False(t, inner.app.latestAppQC.IsPresent(), "latestAppQC should not have been updated") - _, err = inner.pushPruneAnchor(&PruneAnchor{AppQC: makeAppQC(qc1, qc1), CommitQC: qc1}) + require.False(t, inner.app.tip.IsPresent(), "anchor should not have been updated") + _, err = inner.pushAppTip(&AppTip{AppQC: makeAppQC(qc1, qc1), CommitQC: qc1, Epoch: ep}) require.NoError(t, err, "good range, good index should succeed") } } @@ -84,7 +84,7 @@ func TestNewInnerFreshStart(t *testing.T) { i, err := newInner(registry, 0, tc.loaded) require.NoError(t, err) - require.False(t, i.app.latestAppQC.IsPresent()) + require.False(t, i.app.tip.IsPresent()) require.NotNil(t, i.lanes.byID) require.Equal(t, types.RoadIndex(0), i.commits.qcs.first) require.Equal(t, types.RoadIndex(0), i.commits.qcs.next) @@ -100,14 +100,14 @@ func TestNewInnerFreshStart(t *testing.T) { } } -func TestDecodePruneAnchorIncomplete(t *testing.T) { +func TestDecodeAppTipIncomplete(t *testing.T) { rng := utils.TestRng() _, keys := epoch.GenRegistryAt(rng, 4, 0) appProposal := types.NewAppProposal(42, 5, types.GenAppHash(rng), 0) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - _, err := PruneAnchorConv.Decode(&pb.PersistedAvailPruneAnchor{ + _, err := AppTipConv.Decode(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), }) require.Error(t, err) @@ -123,10 +123,10 @@ func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { // missing one on restart is a hard fail (do not weaken that contract). _, err := newInner(registry, epoch.FirstRoad(m), utils.Some(&loadedAvailState{})) require.Error(t, err) - require.Contains(t, err.Error(), "prune anchor required") + require.Contains(t, err.Error(), "app tip required") _, err = newInner(registry, epoch.FirstRoad(m), utils.None[*loadedAvailState]()) require.Error(t, err) - require.Contains(t, err.Error(), "prune anchor required") + require.Contains(t, err.Error(), "app tip required") } func TestNewInnerLoadedBlocksContiguous(t *testing.T) { @@ -312,17 +312,17 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[2]}), - commitQCs: loadedQCs, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[2]}), + commitQCs: loadedQCs, } inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - // latestAppQC should be set by prune. - aq, ok := inner.app.latestAppQC.Get() + // AppQC should be set on the prune anchor. + anchor, ok := inner.app.tip.Get() require.True(t, ok) - require.Equal(t, roadIdx, aq.Proposal().RoadIndex()) + require.Equal(t, roadIdx, anchor.AppQC.Proposal().RoadIndex()) // The prune anchor at road 2 sets commitQCs.first = 2. // Indices 2, 3 and 4 remain; earlier ones are pruned. @@ -372,18 +372,18 @@ func TestNewInnerLoadedAllThree(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[2]}), - commitQCs: loadedQCs, - blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[2]}), + commitQCs: loadedQCs, + blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - // AppQC restored. - aq, ok := inner.app.latestAppQC.Get() + // AppQC restored on the prune anchor. + anchor, ok := inner.app.tip.Get() require.True(t, ok) - require.Equal(t, roadIdx, aq.Proposal().RoadIndex()) + require.Equal(t, roadIdx, anchor.AppQC.Proposal().RoadIndex()) // CommitQCs: prune pushed qcs[2], loading skipped it, added 3 and 4. require.Equal(t, types.RoadIndex(2), inner.commits.qcs.first) @@ -446,7 +446,8 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { appProposal := types.NewAppProposal(10, 2, types.GenAppHash(rng), 0) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - updated, err := i.pushPruneAnchor(&PruneAnchor{AppQC: appQC, CommitQC: qcs[2]}) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + updated, err := i.pushAppTip(&AppTip{AppQC: appQC, CommitQC: qcs[2], Epoch: ep0}) require.NoError(t, err) require.True(t, updated) @@ -478,7 +479,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[5]}), + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[5]}), } inner, err := newInner(registry, 0, utils.Some(loaded)) @@ -507,7 +508,7 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[3]}), } inner, err := newInner(registry, 0, utils.Some(loaded)) @@ -518,10 +519,10 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { require.Equal(t, types.RoadIndex(4), inner.commits.qcs.next) require.NoError(t, utils.TestDiff(qcs[3], inner.commits.qcs.q[3])) - // latestAppQC should be set. - aq, ok := inner.app.latestAppQC.Get() + // AppQC should be set on the prune anchor. + anchor, ok := inner.app.tip.Get() require.True(t, ok) - require.Equal(t, types.RoadIndex(3), aq.Proposal().RoadIndex()) + require.Equal(t, types.RoadIndex(3), anchor.AppQC.Proposal().RoadIndex()) // persistedBlockFirst should be initialized from the anchor's CommitQC. for lane := range registry.LatestEpoch().Committee().Lanes().All() { @@ -597,8 +598,8 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[10]}), - commitQCs: loadedQCs, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[10]}), + commitQCs: loadedQCs, } inner, err := newInner(registry, 0, utils.Some(loaded)) @@ -614,9 +615,9 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { require.NoError(t, utils.TestDiff(qcs[10], latest)) // AppQC should be applied via prune. - aq, ok := inner.app.latestAppQC.Get() + anchor, ok := inner.app.tip.Get() require.True(t, ok) - require.Equal(t, types.RoadIndex(10), aq.Proposal().RoadIndex()) + require.Equal(t, types.RoadIndex(10), anchor.AppQC.Proposal().RoadIndex()) } func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { @@ -646,8 +647,8 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), - commitQCs: loadedQCs, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[3]}), + commitQCs: loadedQCs, } inner, err := newInner(registry, 0, utils.Some(loaded)) @@ -685,8 +686,8 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[2]}), - commitQCs: loadedQCs, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[2]}), + commitQCs: loadedQCs, } _, err := newInner(registry, 0, utils.Some(loaded)) @@ -772,7 +773,7 @@ func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { require.Contains(t, err.Error(), "exceeds capacity") } -func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { +func TestNewInnerAppTipPrunesBlockQueues(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) initialBlock := types.GlobalBlockNumber(0) @@ -803,7 +804,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: pruneQC}), + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: pruneQC}), commitQCs: []persist.LoadedCommitQC{ {Index: 2, QC: qcs[2]}, }, @@ -821,7 +822,7 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { } } -func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { +func TestNewInnerAppTipCommitQCUsedForPrune(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) initialBlock := types.GlobalBlockNumber(0) @@ -839,7 +840,7 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[1]}), + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[1]}), commitQCs: []persist.LoadedCommitQC{ {Index: 1, QC: qcs[1]}, {Index: 2, QC: qcs[2]}, @@ -872,7 +873,7 @@ func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { wantAppFirst := qcs[1].GlobalRange().First ap := types.NewAppProposal(wantAppFirst, qcs[1].Index(), types.GenAppHash(rng), 0) loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{ + appTip: utils.Some(&AppTip{ AppQC: types.NewAppQC(makeAppVotes(keys, ap)), CommitQC: qcs[1], }), @@ -913,7 +914,7 @@ func TestAdvanceEpoch_AddsLanesKeepsOld(t *testing.T) { bogusLane := bogusSK.Public() i.lanes.byID[bogusLane] = newLaneState() - i.advanceEpoch(duo) + i.advanceEpoch(duo.Current) require.Contains(t, i.lanes.byID, bogusLane, "old lanes must be retained until lane-expiry") require.Contains(t, i.lanes.byID, realLane, "active lane removed incorrectly") } @@ -929,14 +930,14 @@ func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { // Collect a lane from Current (will become Prev after advance). var prevLane types.LaneID - for l := range i.epoch.Load().Current.Committee().Lanes().All() { + for l := range i.epoch.Load().Committee().Lanes().All() { prevLane = l break } require.Contains(t, i.lanes.byID, prevLane, "Current lane missing before reweight") duo1 := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(1))) - i.advanceEpoch(duo1) + i.advanceEpoch(duo1.Current) // Prior Current lane is now in Prev — must be retained for boundary QC collection. require.Contains(t, i.lanes.byID, prevLane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") diff --git a/sei-tendermint/internal/autobahn/avail/lane.go b/sei-tendermint/internal/autobahn/avail/lane.go index 8dbc654c65..ce6e570ce4 100644 --- a/sei-tendermint/internal/autobahn/avail/lane.go +++ b/sei-tendermint/internal/autobahn/avail/lane.go @@ -50,8 +50,7 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos // Snapshot Current once for off-lock verify. Unlike PushVote (which parks // until Current accepts the signer), we do not wait for future committees — // lane proposals are not reweighted across epoch advances. - duo := s.epochDuo.Load() - c := duo.Current.Committee() + c := s.epoch.Load().Committee() if err := p.Msg().Verify(c); err != nil { return fmt.Errorf("block.Verify(): %w", err) } @@ -109,23 +108,18 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { h := vote.Msg().Header() // Future-epoch voters park (one stream goroutine) until Current includes them. - var committee *types.Committee - var verifiedEpoch types.EpochIndex - for inner, ctrl := range s.inner.Lock() { - if err := ctrl.WaitUntil(ctx, func() bool { - c := inner.epoch.Load().Current.Committee() - return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) - }); err != nil { - return err - } - duo := inner.epoch.Load() - committee = duo.Current.Committee() - verifiedEpoch = duo.Current.EpochIndex() + epoch, err := s.epoch.Wait(ctx, func(epoch *types.Epoch) bool { + // TODO: this is not a reliable criterion: fix once we have proper line lifecycle management + c := s.epoch.Load().Committee() + return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) + }) + if err != nil { + return err } - if err := vote.Msg().Verify(committee); err != nil { + if err := vote.Msg().Verify(epoch.Committee()); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } - if err := vote.VerifySig(committee); err != nil { + if err := vote.VerifySig(epoch.Committee()); err != nil { return fmt.Errorf("vote.Verify(): %w", err) } for inner, ctrl := range s.inner.Lock() { @@ -139,11 +133,9 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote }); err != nil { return err } - // WaitUntil may release the lock; re-check membership under live Current. - live := inner.epoch.Load() - if live.Current.EpochIndex() != verifiedEpoch && - (live.Current.Committee().Weight(vote.Key()) == 0 || - !live.Current.Committee().HasLane(h.Lane())) { + // Check if the lane is still live + epoch := inner.epoch.Load() + if epoch.Committee().Weight(vote.Key()) == 0 || !epoch.Committee().HasLane(h.Lane()) { return nil } if h.BlockNumber() < q.first { @@ -152,7 +144,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote for q.next <= h.BlockNumber() { q.pushBack(newBlockVotes()) } - if q.q[h.BlockNumber()].pushVote(live.Current, vote).IsPresent() { + if q.q[h.BlockNumber()].pushVote(epoch, vote).IsPresent() { ctrl.Updated() } } @@ -223,8 +215,8 @@ func (s *State) WaitForLaneQCs( for inner, ctrl := range s.inner.Lock() { laneQCs := map[types.LaneID]*types.LaneQC{} for { - ep := inner.epoch.Load().Current - for lane := range ep.Committee().Lanes().All() { + epoch := inner.epoch.Load() + for lane := range epoch.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { if qc, ok := inner.lanes.laneQC(lane, first+i).Get(); ok { @@ -235,7 +227,7 @@ func (s *State) WaitForLaneQCs( } } if len(laneQCs) > 0 { - return laneQCs, ep, nil + return laneQCs, epoch, nil } if err := ctrl.Wait(ctx); err != nil { return nil, nil, err diff --git a/sei-tendermint/internal/autobahn/avail/persistence.go b/sei-tendermint/internal/autobahn/avail/persistence.go index f3d49aee7a..4667ef9dc8 100644 --- a/sei-tendermint/internal/autobahn/avail/persistence.go +++ b/sei-tendermint/internal/autobahn/avail/persistence.go @@ -24,23 +24,16 @@ type persisters struct { // innerFile is the A/B file prefix for avail inner state persistence. const innerFile = "avail_inner" -// PruneAnchor is the decoded form of the persisted prune anchor -// (AppQC + matching CommitQC pair). It serves as the crash-recovery -// pruning boundary. -type PruneAnchor struct { - AppQC *types.AppQC - CommitQC *types.CommitQC -} - -// PruneAnchorConv converts between PruneAnchor and its protobuf representation. -var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAnchor]{ - Encode: func(a *PruneAnchor) *pb.PersistedAvailPruneAnchor { +// AppTipConv maps the live App tip to the persisted prune-watermark proto +// (AppQC + CommitQC). Epoch is live-only and recovered on load. +var AppTipConv = protoutils.Conv[*AppTip, *pb.PersistedAvailPruneAnchor]{ + Encode: func(t *AppTip) *pb.PersistedAvailPruneAnchor { return &pb.PersistedAvailPruneAnchor{ - AppQc: types.AppQCConv.Encode(a.AppQC), - CommitQc: types.CommitQCConv.Encode(a.CommitQC), + AppQc: types.AppQCConv.Encode(t.AppQC), + CommitQc: types.CommitQCConv.Encode(t.CommitQC), } }, - Decode: func(p *pb.PersistedAvailPruneAnchor) (*PruneAnchor, error) { + Decode: func(p *pb.PersistedAvailPruneAnchor) (*AppTip, error) { if p.AppQc == nil || p.CommitQc == nil { return nil, fmt.Errorf("incomplete prune anchor: AppQC=%v CommitQC=%v", p.AppQc != nil, p.CommitQc != nil) } @@ -52,16 +45,16 @@ var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAncho if err != nil { return nil, fmt.Errorf("decode CommitQC: %w", err) } - return &PruneAnchor{AppQC: appQC, CommitQC: commitQC}, nil + return &AppTip{AppQC: appQC, CommitQC: commitQC}, nil }, } // loadPersistedState creates persisters for the given directory option and loads // any existing state from disk. When dir is None, all persisters are no-op -// and no state is loaded. When a prune anchor is present, stale commitQCs and -// blocks below the anchor are filtered out before returning. +// and no state is loaded. When an App tip watermark is present, stale commitQCs +// and blocks below that tip are filtered out before returning. func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailState], persisters, error) { - prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + prunePersister, persistedAppTip, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) if err != nil { return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) } @@ -84,24 +77,24 @@ func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailStat loaded := &loadedAvailState{commitQCs: commitQCs, blocks: blocks} - if raw, ok := persistedPruneAnchor.Get(); ok { - anchor, err := PruneAnchorConv.Decode(raw) + if raw, ok := persistedAppTip.Get(); ok { + tip, err := AppTipConv.Decode(raw) if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("decode prune anchor: %w", err) + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("decode app tip: %w", err) } - loaded.pruneAnchor = utils.Some(anchor) + loaded.appTip = utils.Some(tip) - anchorIdx := anchor.AppQC.Proposal().RoadIndex() + tipIdx := tip.AppQC.Proposal().RoadIndex() filtered := commitQCs[:0] for _, lqc := range commitQCs { - if lqc.Index >= anchorIdx { + if lqc.Index >= tipIdx { filtered = append(filtered, lqc) } } loaded.commitQCs = filtered for lane, bs := range blocks { - first := anchor.CommitQC.LaneRange(lane).First() + first := tip.CommitQC.LaneRange(lane).First() j := 0 for j < len(bs) && bs[j].Number < first { j++ @@ -117,14 +110,14 @@ func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailStat // runPersist is the main loop for the persist goroutine. // Write order: -// 1. Prune anchor (AppQC + CommitQC pair) — the crash-recovery boundary (sequential). +// 1. App tip snapshot (AppQC + CommitQC) — the crash-recovery prune watermark (sequential). // 2. commitQCs.MaybePruneAndPersist and each lane's blocks.MaybePruneAndPersistLane run // concurrently via scope.Parallel (separate WALs, no early cancellation; first error // is returned after all tasks finish). // Each path publishes (markCommitQCsPersisted / markBlockPersisted) per entry so voting // unblocks ASAP. // -// On restart, the persisted prune anchor establishes the retained boundary. +// On restart, the persisted App tip establishes the retained boundary. // // TODO: use a single WAL for anchor and CommitQCs to make // this atomic rather than relying on write order. @@ -136,17 +129,17 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { return err } - // The same anchor CommitQC drives commit-QC WAL and per-lane block WAL + // The same tip CommitQC drives commit-QC WAL and per-lane block WAL // (truncate-then-append below this QC). - var anchorQC utils.Option[*types.CommitQC] - // 1. Persist prune anchor first — establishes the crash-recovery boundary. - if anchor, ok := batch.pruneAnchor.Get(); ok { - if err := pers.pruneAnchor.Persist(PruneAnchorConv.Encode(anchor)); err != nil { - return fmt.Errorf("persist prune anchor: %w", err) + var tipQC utils.Option[*types.CommitQC] + // 1. Persist App tip first — establishes the crash-recovery prune watermark. + if tip, ok := batch.appTip.Get(); ok { + if err := pers.pruneAnchor.Persist(AppTipConv.Encode(tip)); err != nil { + return fmt.Errorf("persist app tip: %w", err) } - s.advancePersistedBlockStart(anchor.CommitQC) - persistedAnchorNext = anchor.CommitQC.Proposal().Index() + 1 - anchorQC = utils.Some(anchor.CommitQC) + s.advancePersistedBlockStart(tip.CommitQC) + persistedAnchorNext = tip.CommitQC.Proposal().Index() + 1 + tipQC = utils.Some(tip.CommitQC) } markBlock := func(p *types.Signed[*types.LaneProposal]) { @@ -164,12 +157,12 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { // Callees handle empty inputs gracefully (no-op when nothing to write/truncate). if err := scope.Parallel(func(ps scope.ParallelScope) error { ps.Spawn(func() error { - return pers.commitQCs.MaybePruneAndPersist(anchorQC, batch.commitQCs, utils.Some(func(qc *types.CommitQC) { + return pers.commitQCs.MaybePruneAndPersist(tipQC, batch.commitQCs, utils.Some(func(qc *types.CommitQC) { s.markCommitQCsPersisted(qc) })) }) // Collect lanes: any lane with blocks in this batch, plus all lanes - // in the anchor epoch (for WAL pruning). + // in the tip epoch (for WAL pruning). // TODO: when epoch transitions land, also union in lanes from all // epochs that appear in batch.commitQCs so new-epoch lanes are // never skipped in a cross-epoch batch. @@ -177,13 +170,13 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { for lane := range blocksByLane { batchLanes[lane] = struct{}{} } - for _, lane := range batch.pruneLanes { + for _, lane := range batch.tipLanes { batchLanes[lane] = struct{}{} } for lane := range batchLanes { proposals := blocksByLane[lane] ps.Spawn(func() error { - return pers.blocks.MaybePruneAndPersistLane(lane, anchorQC, proposals, utils.Some(markBlock)) + return pers.blocks.MaybePruneAndPersistLane(lane, tipQC, proposals, utils.Some(markBlock)) }) } return nil @@ -194,15 +187,17 @@ func (s *State) runPersist(ctx context.Context, pers persisters) error { } // persistBatch holds the data collected under lock for one persist iteration. +// appTip is a snapshot of app's live tip for the prune watermark — persist +// does not own or look up the tip's CommitQC separately. type persistBatch struct { - blocks []*types.Signed[*types.LaneProposal] - commitQCs []*types.CommitQC - pruneAnchor utils.Option[*PruneAnchor] - pruneLanes []types.LaneID + blocks []*types.Signed[*types.LaneProposal] + commitQCs []*types.CommitQC + appTip utils.Option[*AppTip] + tipLanes []types.LaneID } // advancePersistedBlockStart updates the per-lane block admission boundary -// after durably writing the prune anchor. This unblocks PushBlock/ProduceBlock +// after durably writing the App tip watermark. This unblocks PushBlock/ProduceBlock // waiters that are gated on persistedBlockFirst + BlocksPerLane. func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { for inner, ctrl := range s.inner.Lock() { @@ -243,13 +238,13 @@ func (s *State) collectPersistBatch(ctx context.Context, persistedAnchorNext typ for inner, ctrl := range s.inner.Lock() { // Derive the CommitQC persist cursor from persistedCommitQC. This is // safe because persistedCommitQC is only advanced by markCommitQCsPersisted - // (after disk write) and on startup (from disk). Applying a prune anchor + // (after disk write) and on startup (from disk). Advancing the App tip // does not update persistedCommitQC, so this always reflects persistence - // state. The max clamp with commits.qcs.first handles an anchor + // state. The max clamp with commits.qcs.first handles a tip // fast-forwarding the queue past the cursor. commitQCNext := types.NextIndexOpt(inner.commits.persistedCommitQC.Load()) if err := ctrl.WaitUntil(ctx, func() bool { - if types.NextOpt(inner.app.latestAppQC) != persistedAnchorNext { + if types.NextOpt(inner.app.tip) != persistedAnchorNext { return true } for _, ls := range inner.lanes.byID { @@ -271,23 +266,13 @@ func (s *State) collectPersistBatch(ctx context.Context, persistedAnchorNext typ for n := commitQCNext; n < inner.commits.qcs.next; n++ { b.commitQCs = append(b.commitQCs, inner.commits.qcs.q[n]) } - if types.NextOpt(inner.app.latestAppQC) != persistedAnchorNext { - if appQC, ok := inner.app.latestAppQC.Get(); ok { - idx := appQC.Proposal().RoadIndex() - if qc, ok := inner.commits.qcs.q[idx]; ok { - b.pruneAnchor = utils.Some(&PruneAnchor{ - AppQC: appQC, - CommitQC: qc, - }) - // Capture under the same lock as the anchor so an epoch slide - // cannot move its committee out of the live duo before I/O. - ep, err := inner.epoch.Load().EpochForRoad(qc.Proposal().Index()) - if err != nil { - return b, fmt.Errorf("EpochForRoad(%d): %w", qc.Proposal().Index(), err) - } - for lane := range ep.Committee().Lanes().All() { - b.pruneLanes = append(b.pruneLanes, lane) - } + if types.NextOpt(inner.app.tip) != persistedAnchorNext { + if tip, ok := inner.app.tip.Get(); ok { + b.appTip = utils.Some(tip) + // Capture under the same lock as the tip so an epoch slide + // cannot move its committee before I/O. + for lane := range tip.Epoch.Committee().Lanes().All() { + b.tipLanes = append(b.tipLanes, lane) } } } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index ffdcbd04ba..64f6226904 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -25,10 +25,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] - epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epoch + key types.SecretKey + data *data.State + inner utils.Watch[*inner] + epoch utils.AtomicRecv[*types.Epoch] // Load-only view of inner.epoch // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. @@ -65,7 +65,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin key: key, data: data, inner: utils.NewWatch(inner), - epochDuo: inner.epoch.Subscribe(), + epoch: inner.epoch.Subscribe(), persisters: pers, }, nil } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 4ed98ad45d..ec6d0ff5cd 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -26,8 +26,10 @@ var ( noCommitQCCB = utils.None[func(*types.CommitQC)]() ) -// registerDuoAtEpoch installs Prev=n-1|Current=n as the state's operating -// window. Seeds those registry epochs only (no restart N+1 lookahead). +// registerDuoAtEpoch installs Current=n as the state's operating epoch. +// Seeds Prev|Current in the registry only (no restart N+1 lookahead). +// Does not install an App tip — tests that need App lag (Prev admit) must +// setAppTip, matching production where tip stays in Prev after a sealed advance. func registerDuoAtEpoch(s *State, n types.EpochIndex) { r := s.data.Registry() if n > 0 { @@ -36,7 +38,17 @@ func registerDuoAtEpoch(s *State, n types.EpochIndex) { r.EnsureEpoch(n) duo := utils.OrPanic1(r.DuoAt(epoch.FirstRoad(n))) for inner := range s.inner.Lock() { - inner.epoch.Store(duo) + inner.epoch.Store(duo.Current) + } +} + +// setAppTip installs an App tip for tests that exercise seal leashes +// without going through PushAppQC. +func setAppTip(s *State, appQC *types.AppQC, commitQC *types.CommitQC) { + ep := utils.OrPanic1(s.data.Registry().EpochAt(appQC.Proposal().RoadIndex())) + for inner, ctrl := range s.inner.Lock() { + inner.app.tip = utils.Some(&AppTip{AppQC: appQC, CommitQC: commitQC, Epoch: ep}) + ctrl.Updated() } } @@ -46,8 +58,8 @@ func advanceUntilCurrent(t *testing.T, s *State, want types.EpochIndex) { ctx, cancel := context.WithCancel(t.Context()) errCh := make(chan error, 1) go func() { errCh <- s.runAdvanceEpoch(ctx) }() - _, err := s.epochDuo.Wait(t.Context(), func(duo types.EpochDuo) bool { - return duo.Current.EpochIndex() >= want + _, err := s.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= want }) require.NoError(t, err) cancel() @@ -450,7 +462,8 @@ func TestWaitForAppQC(t *testing.T) { canceled, cancel := context.WithCancel(ctx) cancel() - require.ErrorIs(t, state.waitForAppQC(canceled, 0), context.Canceled) + _, _, err = state.WaitForAppQC(canceled, 0) + require.ErrorIs(t, err, context.Canceled) lane := keys[0].Public() b, err := state.ProduceLocalBlock(state.NextBlock(lane), types.GenPayload(rng)) @@ -467,14 +480,19 @@ func TestWaitForAppQC(t *testing.T) { qc0.GlobalRange().Next-1, 0, types.GenAppHash(rng), 0))) done := make(chan error, 1) - go func() { done <- state.waitForAppQC(ctx, 0) }() + go func() { + _, _, err := state.WaitForAppQC(ctx, 0) + done <- err + }() require.NoError(t, state.PushAppQC(ctx, appQC, qc0)) require.NoError(t, <-done) - require.NoError(t, state.waitForAppQC(ctx, 0)) + _, _, err = state.WaitForAppQC(ctx, 0) + require.NoError(t, err) canceled2, cancel2 := context.WithCancel(ctx) cancel2() - require.ErrorIs(t, state.waitForAppQC(canceled2, 1), context.Canceled) + _, _, err = state.WaitForAppQC(canceled2, 1) + require.ErrorIs(t, err, context.Canceled) } // TestPushVote_WaitsForFutureEpochSigner: a voter not yet in Current parks until @@ -487,7 +505,6 @@ func TestPushVote_WaitsForFutureEpochSigner(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) - ep0 := state.epochDuo.Load().Current futureKey := types.GenSecretKey(rng) lane := futureKey.Public() header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() @@ -503,9 +520,8 @@ func TestPushVote_WaitsForFutureEpochSigner(t *testing.T) { } ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, utils.OrPanic1(types.NewCommittee(weights))) - duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) for inner, ctrl := range state.inner.Lock() { - inner.advanceEpoch(duo1) + inner.advanceEpoch(ep1) ctrl.Updated() } synctest.Wait() @@ -548,7 +564,6 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) - ep0 := state.epochDuo.Load().Current lane := keys[0].Public() n := types.BlockNumber(BlocksPerLane) // WaitUntil: n >= persistedBlockFirst+BlocksPerLane header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() @@ -559,14 +574,13 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { weights[k.Public()] = 1000 } ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, utils.OrPanic1(types.NewCommittee(weights))) - duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) errCh := make(chan error, 1) go func() { errCh <- state.PushVote(ctx, vote) }() synctest.Wait() // blocked in WaitUntil (capacity) for inner, ctrl := range state.inner.Lock() { - inner.advanceEpoch(duo1) + inner.advanceEpoch(ep1) inner.lanes.byID[lane].durable.persistedBlockFirst = 1 // unblock: n < 1+BlocksPerLane ctrl.Updated() } @@ -574,7 +588,7 @@ func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { require.NoError(t, <-errCh) for inner := range state.inner.Lock() { - require.Equal(t, types.EpochIndex(1), inner.epoch.Load().Current.EpochIndex()) + require.Equal(t, types.EpochIndex(1), inner.epoch.Load().EpochIndex()) require.Equal(t, types.BlockNumber(0), inner.lanes.byID[lane].votes.next, "dropped vote must not extend the queue") } @@ -591,7 +605,6 @@ func TestPushVote_DropsLaneAfterEpochAdvance(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) - ep0 := state.epochDuo.Load().Current lane := keys[1].Public() n := types.BlockNumber(BlocksPerLane) header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() @@ -603,14 +616,13 @@ func TestPushVote_DropsLaneAfterEpochAdvance(t *testing.T) { } ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, utils.OrPanic1(types.NewCommittee(weights))) - duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) errCh := make(chan error, 1) go func() { errCh <- state.PushVote(ctx, vote) }() synctest.Wait() for inner, ctrl := range state.inner.Lock() { - inner.advanceEpoch(duo1) + inner.advanceEpoch(ep1) inner.lanes.byID[lane].durable.persistedBlockFirst = 1 ctrl.Updated() } @@ -634,7 +646,6 @@ func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) - ep0 := state.epochDuo.Load().Current lane := keys[0].Public() n := types.BlockNumber(BlocksPerLane) header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() @@ -642,14 +653,13 @@ func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { weights := map[types.PublicKey]uint64{keys[0].Public(): 1000, keys[1].Public(): 1000} ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, utils.OrPanic1(types.NewCommittee(weights))) - duo1 := types.NewEpochDuo(ep1, utils.Some(ep0)) errCh := make(chan error, 1) go func() { errCh <- state.PushVote(ctx, vote) }() synctest.Wait() for inner, ctrl := range state.inner.Lock() { - inner.advanceEpoch(duo1) + inner.advanceEpoch(ep1) inner.lanes.byID[lane].durable.persistedBlockFirst = 1 ctrl.Updated() } @@ -1098,15 +1108,16 @@ func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { })) } -func TestPushAppQCOutsideWindowDrops(t *testing.T) { +func TestPushAppQCStaleBehindAnchorDrops(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) - registry.EnsureEpoch(m + 1) epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) + registerDuoAtEpoch(state, m) parentTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ @@ -1115,14 +1126,28 @@ func TestPushAppQCOutsideWindowDrops(t *testing.T) { }))), }) qcPrev := makeCommitQC(epPrev, keys, utils.Some(parentTip), nil, utils.None[*types.AppQC]()) - appQC := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) - // Slide Current to M+1 so M-1 falls behind WindowFirst. - registerDuoAtEpoch(state, m+1) - - require.NoError(t, state.PushAppQC(t.Context(), appQC, qcPrev)) - require.False(t, state.LastAppQC().IsPresent()) + qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + + require.NoError(t, state.PushAppQC(t.Context(), appQCM, qcM)) + tip, ok := state.LastAppQC().Get() + require.True(t, ok) + require.Equal(t, qcM.Index(), tip.Proposal().RoadIndex()) + + // Older AppQC behind the anchor tip is a no-op drop. + require.NoError(t, state.PushAppQC(t.Context(), appQCPrev, qcPrev)) + tip2, ok := state.LastAppQC().Get() + require.True(t, ok) + require.Equal(t, tip.Proposal().RoadIndex(), tip2.Proposal().RoadIndex()) } func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { @@ -1162,23 +1187,18 @@ func TestPushAppVoteFarFutureParks(t *testing.T) { require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) } -// TestWaitCurrentVsDuoRoad: a road in Prev is too late for Current-only -// admission (CommitQC), even though duo admission would still resolve it. -func TestWaitCurrentVsDuoRoad(t *testing.T) { +// TestEpochPastCurrentIsPruned: Epoch(i) when Current has moved past i returns ErrPruned. +func TestEpochPastCurrentIsPruned(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - registerDuoAtEpoch(state, m) // Prev=M-1|Current=M - - roadInPrev := epoch.FirstRoad(m - 1) - _, err = state.waitForEpochDuo(t.Context(), roadInPrev) - require.NoError(t, err, "Prev|Current window still covers Prev roads") + registerDuoAtEpoch(state, m) // Current=M - _, err = state.waitForEpoch(t.Context(), roadInPrev) - require.ErrorIs(t, err, types.ErrPruned, "Current-only wait must treat Prev roads as too late") + _, err = state.Epoch(t.Context(), m-1) + require.ErrorIs(t, err, types.ErrPruned, "past Current must be ErrPruned") } func TestPushCommitQCStaleDrops(t *testing.T) { @@ -1208,21 +1228,18 @@ func TestPushCommitQCStaleDrops(t *testing.T) { require.Equal(t, tipBefore, state.LastCommitQC().Load(), "stale CommitQC must not advance tip") } -// 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. +// TestFullCommitQCBeforeWindowIsPruned: CommitQC road below commits.qcs.first +// returns ErrPruned so the export loop can jump. func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) - registry.EnsureEpoch(m + 1) - registry.EnsureEpoch(m + 2) epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) + registerDuoAtEpoch(state, m) prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ @@ -1233,22 +1250,20 @@ func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { qc := makeCommitQC(epM, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) require.Equal(t, epoch.FirstRoad(m), qc.Proposal().Index()) - // Plant an admitted QC, then slide the duo past it (skip live PushCommitQC). + // Tip has pruned past the road; export must observe ErrPruned. for inner := range state.inner.Lock() { - inner.commits.qcs.first = epoch.FirstRoad(m) + inner.commits.qcs.first = epoch.FirstRoad(m) + 1 inner.commits.qcs.next = epoch.FirstRoad(m) + 1 - inner.commits.qcs.q[epoch.FirstRoad(m)] = qc } state.markCommitQCsPersisted(qc) - registerDuoAtEpoch(state, m+2) _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(m)) require.ErrorIs(t, err, types.ErrPruned) } -// TestFullCommitQCAfterWindowHardFails: road ahead of Current is unexpected for -// an admitted CommitQC — ErrRoadAfterWindow, not a wait. -func TestFullCommitQCAfterWindowHardFails(t *testing.T) { +// TestFullCommitQCNeedsAppAnchorEpoch: an admitted CommitQC whose epoch is not +// Current hard-fails without an App-anchor epoch (not a wait, not ErrPruned). +func TestFullCommitQCNeedsAppAnchorEpoch(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) @@ -1258,7 +1273,7 @@ func TestFullCommitQCAfterWindowHardFails(t *testing.T) { state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - // Operating window still on Prev; plant a CommitQC for Current's first road. + // Current still on Prev; plant a CommitQC for epoch M. registerDuoAtEpoch(state, m-1) prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ @@ -1278,7 +1293,7 @@ func TestFullCommitQCAfterWindowHardFails(t *testing.T) { state.markCommitQCsPersisted(qc1) _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(m)) - require.ErrorIs(t, err, types.ErrRoadAfterWindow) + require.Error(t, err) require.NotErrorIs(t, err, types.ErrPruned) } @@ -1319,11 +1334,12 @@ func TestPushCommitQCMidEpochNoExecLeash(t *testing.T) { } } -// TestPushCommitQCWaitsForEpochUnlock: seal admit waits on registry M+1 -// (execution leash) before inserting the last CommitQC of M. -func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { +// TestRunAdvanceEpochWaitsForRegistry: seal advance waits on registry M+1 +// (execution leash). PushCommitQC itself admits without that wait. +func TestRunAdvanceEpochWaitsForRegistry(t *testing.T) { synctest.Test(t, func(t *testing.T) { - ctx := t.Context() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) @@ -1356,33 +1372,36 @@ func TestPushCommitQCWaitsForEpochUnlock(t *testing.T) { require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { - inner.app.latestAppQC = utils.Some(appQCM) inner.commits.qcs.first = epoch.LastRoad(m) inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) + setAppTip(state, appQCM, qcMid) + + require.NoError(t, state.PushCommitQC(ctx, qcLast)) + require.Equal(t, m, state.epoch.Load().EpochIndex()) errCh := make(chan error, 1) - go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() - synctest.Wait() // parked on WaitForDuo(M+1) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m), inner.commits.qcs.next, "not admitted until exec leash") - } + go func() { errCh <- state.runAdvanceEpoch(ctx) }() + synctest.Wait() // parked on WaitForEpoch(M+1) + require.Equal(t, m, state.epoch.Load().EpochIndex(), "not advanced until exec leash") registry.EnsureEpoch(m + 1) - synctest.Wait() - require.NoError(t, <-errCh) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) - } + _, err = state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= m+1 + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) }) } -// TestPushAppQCWaitsForEpochUnlock: seal PushAppQC admits after AppQC (prune -// leash satisfied by incoming) but waits on registry M+1 (execution leash). -func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { +// TestPushAppQCAdmitsWithoutExecLeash: seal PushAppQC admits the tipcut without +// registry M+1; runAdvanceEpoch is what waits on the execution leash. +func TestPushAppQCAdmitsWithoutExecLeash(t *testing.T) { synctest.Test(t, func(t *testing.T) { - ctx := t.Context() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) @@ -1412,27 +1431,33 @@ func TestPushAppQCWaitsForEpochUnlock(t *testing.T) { } state.markCommitQCsPersisted(prevOnLast) - errCh := make(chan error, 1) - go func() { errCh <- state.PushAppQC(ctx, appQCLast, qcLast) }() - synctest.Wait() + require.NoError(t, state.PushAppQC(ctx, appQCLast, qcLast)) for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m), inner.commits.qcs.next, "not admitted until exec leash") + require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) } + require.Equal(t, m, state.epoch.Load().EpochIndex()) - registry.EnsureEpoch(m + 1) + errCh := make(chan error, 1) + go func() { errCh <- state.runAdvanceEpoch(ctx) }() synctest.Wait() - require.NoError(t, <-errCh) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) - } + require.Equal(t, m, state.epoch.Load().EpochIndex(), "advance parked on registry M+1") + + registry.EnsureEpoch(m + 1) + _, err = state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= m+1 + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) }) } -// TestPushCommitQCBoundaryWaitsForAppQCInEpoch: last CommitQC of epoch M is not -// admitted until AppQC for M exists (prune leash on CommitQC admission). -func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { +// TestRunAdvanceEpochWaitsForAppAnchor: runAdvanceEpoch does not leave Current +// until the App-anchor epoch covers it (prune leash). PushCommitQC admits freely. +func TestRunAdvanceEpochWaitsForAppAnchor(t *testing.T) { synctest.Test(t, func(t *testing.T) { - ctx := t.Context() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 4) registry.EnsureEpoch(m + 1) // exec leash for sealing M @@ -1464,18 +1489,18 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) for inner := range state.inner.Lock() { - inner.app.latestAppQC = utils.Some(appQCPrev) // only M-1 — not enough to close M inner.commits.qcs.first = epoch.LastRoad(m) inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) + setAppTip(state, appQCPrev, qcPrev) // only M-1 — not enough to close M + + require.NoError(t, state.PushCommitQC(ctx, qcLast)) errCh := make(chan error, 1) - go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() + go func() { errCh <- state.runAdvanceEpoch(ctx) }() synctest.Wait() - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m), inner.commits.qcs.next, "not admitted without AppQC in M") - } + require.Equal(t, m, state.epoch.Load().EpochIndex(), "not advanced without AppQC in M") qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ @@ -1485,23 +1510,23 @@ func TestPushCommitQCBoundaryWaitsForAppQCInEpoch(t *testing.T) { })), nil, utils.None[*types.AppQC]()) appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.EpochIndex()))) - for inner, ctrl := range state.inner.Lock() { - inner.app.latestAppQC = utils.Some(appQCM) - ctrl.Updated() - } - synctest.Wait() - require.NoError(t, <-errCh) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) - } + setAppTip(state, appQCM, qcM) + + _, err = state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= m+1 + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) }) } -// TestPushCommitQCEpoch0SealWaitsForAppQC: no epoch-0 exemption — admitting -// LastRoad(0) waits for AppQC before insert. -func TestPushCommitQCEpoch0SealWaitsForAppQC(t *testing.T) { +// TestRunAdvanceEpochEpoch0WaitsForAppAnchor: no epoch-0 exemption — advancing +// past 0 waits for an App anchor covering epoch 0. +func TestRunAdvanceEpochEpoch0WaitsForAppAnchor(t *testing.T) { synctest.Test(t, func(t *testing.T) { - ctx := t.Context() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() rng := utils.TestRng() registry, keys := epoch.GenRegistryAt(rng, 4, 0) registry.EnsureEpoch(1) // exec leash for sealing 0 @@ -1526,24 +1551,24 @@ func TestPushCommitQCEpoch0SealWaitsForAppQC(t *testing.T) { } state.markCommitQCsPersisted(prevOnLast) + require.NoError(t, state.PushCommitQC(ctx, qcLast)) + errCh := make(chan error, 1) - go func() { errCh <- state.PushCommitQC(ctx, qcLast) }() + go func() { errCh <- state.runAdvanceEpoch(ctx) }() synctest.Wait() - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(0), inner.commits.qcs.next) - } + require.Equal(t, types.EpochIndex(0), state.epoch.Load().EpochIndex()) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), nil, utils.None[*types.AppQC]()) appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( 0, 0, types.GenAppHash(rng), 0))) - for inner, ctrl := range state.inner.Lock() { - inner.app.latestAppQC = utils.Some(appQC0) - ctrl.Updated() - } - synctest.Wait() - require.NoError(t, <-errCh) - for inner := range state.inner.Lock() { - require.Equal(t, epoch.LastRoad(0)+1, inner.commits.qcs.next) - } + setAppTip(state, appQC0, qc0) + + _, err = state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= 1 + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) }) } @@ -1582,16 +1607,16 @@ func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.EpochIndex()))) for inner := range state.inner.Lock() { - inner.app.latestAppQC = utils.Some(appQCPrev) // stale; PushAppQC prune installs appQCLast inner.commits.qcs.first = epoch.LastRoad(m) inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) + setAppTip(state, appQCPrev, qcPrev) // stale; PushAppQC prune installs appQCLast require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) for inner := range state.inner.Lock() { require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) - require.Equal(t, m, inner.epoch.Load().Current.EpochIndex()) + require.Equal(t, m, inner.epoch.Load().EpochIndex()) } advanceUntilCurrent(t, state, m+1) } @@ -1632,14 +1657,14 @@ func TestEpochAdvanceGapHandoff(t *testing.T) { require.Equal(t, epoch.FirstRoad(m+1), qcNext.Proposal().Index()) for inner := range state.inner.Lock() { - inner.app.latestAppQC = utils.Some(appQCM) inner.commits.qcs.first = epoch.FirstRoad(m) inner.commits.qcs.next = epoch.LastRoad(m) } state.markCommitQCsPersisted(prevOnLast) + setAppTip(state, appQCM, qcMid) require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) - require.Equal(t, m, state.epochDuo.Load().Current.EpochIndex()) + require.Equal(t, m, state.epoch.Load().EpochIndex()) state.markCommitQCsPersisted(qcLast) // satisfy waitForCommitQC for FirstRoad(m+1) advCtx, advCancel := context.WithCancel(t.Context()) @@ -1647,7 +1672,7 @@ func TestEpochAdvanceGapHandoff(t *testing.T) { go func() { advErr <- state.runAdvanceEpoch(advCtx) }() require.NoError(t, state.PushCommitQC(t.Context(), qcNext)) - require.Equal(t, m+1, state.epochDuo.Load().Current.EpochIndex()) + require.Equal(t, m+1, state.epoch.Load().EpochIndex()) advCancel() require.ErrorIs(t, <-advErr, context.Canceled) }) @@ -1666,7 +1691,7 @@ func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { registerDuoAtEpoch(state, m-1) // Satisfy waitForCommitQC(FirstRoad(m)-1) without pushing EpochLength QCs. - // Current remains M-1, so FirstRoad(m) is too early for waitForEpoch. + // Current remains M-1, so FirstRoad(m) parks on Epoch(M). tipQC := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ EpochIndex: m - 1, @@ -1689,7 +1714,7 @@ func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { // TestPushAppQCPreviousEpoch verifies that a late AppQC whose road falls in // Prev is accepted after Current has advanced. Its committee is resolved from -// Prev, not Current. +// the App-tip epoch (Prev), not Current. func TestPushAppQCPreviousEpoch(t *testing.T) { rng := utils.TestRng() registry, keys, m := epoch.GenRegistryTip(rng, 3) @@ -1701,6 +1726,10 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { Index: epoch.LastRoad(m-1) - 1, }))), }) + grPrev := prevTip.GlobalRange() + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + grPrev.First, prevTip.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) + commitQC := makeCommitQC(epPrev, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) gr := commitQC.GlobalRange() appProposal := types.NewAppProposal(gr.First, commitQC.Index(), types.GenAppHash(rng), epPrev.EpochIndex()) @@ -1711,6 +1740,8 @@ func TestPushAppQCPreviousEpoch(t *testing.T) { require.NoError(t, err) registerDuoAtEpoch(state, m) + // Sealed advance leaves App tip in Prev while Current is M. + setAppTip(state, appQCPrev, prevTip) require.NoError(t, state.PushAppQC(t.Context(), appQC, commitQC), "late AppQC from Prev should be accepted after Current advanced") @@ -1740,7 +1771,7 @@ func TestNextCommitQC(t *testing.T) { 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), (&loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC1, CommitQC: qcs[1]}), + appTip: utils.Some(&AppTip{AppQC: appQC1, CommitQC: qcs[1]}), commitQCs: []persist.LoadedCommitQC{ {Index: 1, QC: qcs[1]}, {Index: 2, QC: qcs[2]}, @@ -1751,10 +1782,10 @@ func TestNextCommitQC(t *testing.T) { 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), (&loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC9, CommitQC: qcs[9]}), + appTip: utils.Some(&AppTip{AppQC: appQC9, CommitQC: qcs[9]}), }).nextCommitQC()) require.Equal(t, types.RoadIndex(10), (&loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC9, CommitQC: qcs[9]}), + appTip: utils.Some(&AppTip{AppQC: appQC9, CommitQC: qcs[9]}), commitQCs: []persist.LoadedCommitQC{ {Index: 0, QC: qcs[0]}, {Index: 1, QC: qcs[1]}, diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 35975945e1..dffb9078d0 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -440,7 +440,7 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma } // PushQC atomically admits qc and optional finalized blocks. -// Tip-order WaitUntil runs before EpochForRoad so a first QC of the next epoch +// Tip-order WaitUntil runs before ByRoad so a first QC of the next epoch // waits for the boundary slide (rather than ErrRoadAfterWindow). Epoch via // epochDuo only (not Registry). Before-window on a still-needed QC hard-fails; // if needQC is false the QC is already applied and a before-window miss is a @@ -465,9 +465,9 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty } idx := qc.QC().Proposal().Index() duo := s.epochDuo.Load() - ep, err := duo.EpochForRoad(idx) + ep, err := duo.ByRoad(idx) if err != nil { - if !needQC && errors.Is(err, types.ErrRoadBeforeWindow) { + if !needQC && errors.Is(err, types.ErrPruned) { return nil } return err @@ -560,7 +560,7 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block } // n in [nextBlock, nextQC): QC is contiguous in that range. var err error - ep, err = s.epochDuo.Load().EpochForRoad(inner.qcs[n].QC().Proposal().Index()) + ep, err = s.epochDuo.Load().ByRoad(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/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 42f0aaf475..8d86ac31e3 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -29,20 +29,16 @@ func LastRoad(idx types.EpochIndex) types.RoadIndex { type registryState = map[types.EpochIndex]*types.Epoch -// Registry is the authoritative store of epoch/committee metadata for all +// Registry is the authoritative catalog of epoch/committee metadata for all // layers (consensus, data, avail). // // Invariants: -// - Independent of each layer's live EpochDuo (Prev|Current). Duo admits -// traffic; the registry may retain more epochs for restart and leashes. +// - Catalog, not a live admit window. Each layer caches the epochs it needs +// to verify its own tip (avail: Current + App-anchor epoch; consensus: +// Current + Prev for AppQC lag). // - Execution cannot pass commit. Sealing epoch N (including 0) requires -// registry N+1 (execution leash) and AppQC in epoch N before the window -// slides (prune leash). Epoch 0 is intentionally not exempt: even though -// {∅,0}→{0,1} drops no Prev, the leash is what guarantees an AppQC anchor -// before Current leaves 0 (newInner hard-fails without one). Peer -// PushCommitQC can seal LastRoad(0) without local BlocksPerLane pressure, -// so "unreachable under BlocksPerLane" is not a valid exemption. Finishing -// LastRoad(N-1) seeds epoch N+1 (AdvanceIfNeeded). +// registry N+1 (execution leash) and AppQC in epoch N before avail Current +// leaves N (prune leash). // - data/ is the sole restart seeder (SetupInitialDuo). Avail/consensus must // not seed; tip into an unseeded epoch → EpochAt/DuoAt hard-fail. // - Post-construction tipcuts: avail ≥ consensus. Consensus and avail may @@ -55,10 +51,7 @@ type registryState = map[types.EpochIndex]*types.Epoch // // TODO(autobahn): replace genesis placeholders with epoch info on blocks. type Registry struct { - state utils.RWMutex[registryState] - // epochGen bumps on every new registration; WaitForDuo waits on it so - // filling a gap still wakes waiters. - epochGen utils.AtomicSend[uint64] + state utils.Watch[registryState] // Genesis floors from GenDoc (InitialHeight / GenesisTime). genesisFirstBlock types.GlobalBlockNumber genesisTimestamp time.Time @@ -74,8 +67,7 @@ func NewRegistry( ) (*Registry, error) { ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) return &Registry{ - state: utils.NewRWMutex(registryState{0: ep}), - epochGen: utils.NewAtomicSend(uint64(0)), + state: utils.NewWatch(registryState{0: ep}), genesisFirstBlock: firstBlock, genesisTimestamp: genesisTimestamp, genesisCommittee: committee, @@ -101,12 +93,13 @@ func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) erro // Avail WAL and BlockDB prune independently. Avail may restart at the // retained span's first epoch and still need its Prev. r.EnsureDuoAt(span.First) - for s := range r.state.Lock() { + for s, ctrl := range r.state.Lock() { for idx := windowFirst; idx <= windowLast; idx++ { if _, ok := s[idx]; ok { continue } r.makeEpoch(s, idx) + ctrl.Updated() } } r.EnsureDuoAt(span.Next) @@ -139,7 +132,7 @@ func (r *Registry) FirstTimestamp() time.Time { // Error if that epoch is not registered. func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { epochIdx := IndexForRoad(roadIndex) - for s := range r.state.RLock() { + for s := range r.state.Lock() { if ep, ok := s[epochIdx]; ok { return ep, nil } @@ -157,20 +150,15 @@ func (r *Registry) makeEpoch(s registryState, epochIdx types.EpochIndex) *types. firstRoad := FirstRoad(epochIdx) epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)}, r.genesisCommittee) s[epochIdx] = epoch - r.epochGen.Store(r.epochGen.Load() + 1) return epoch } // 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[idx]; ok { - return - } - } - for s := range r.state.Lock() { + for s, ctrl := range r.state.Lock() { if _, ok := s[idx]; !ok { r.makeEpoch(s, idx) + ctrl.Updated() } } } @@ -185,7 +173,7 @@ func (r *Registry) EnsureDuoAt(road types.RoadIndex) { } // AdvanceIfNeeded seeds epoch M+2 when roadIndex is LastRoad(M); else no-op. -// Also ensures M+1 so WaitForDuo(FirstRoad(M+2)) is not stuck on a Prev gap. +// Also ensures M+1 so WaitForEpoch(M+1) is not stuck when sealing M. // Call only after the last global of that road has executed (IsLastBlock). // // TODO(autobahn): pass the real M+2 committee once execution derives it. @@ -204,36 +192,52 @@ func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { // Current must already be registered. Prev absent only for epoch 0; missing // Prev for center > 0 is a hard error (no soft-degrade to Current-only). func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { - centerIdx := IndexForRoad(roadIndex) - current, err := r.EpochAt(FirstRoad(centerIdx)) + current, err := r.EpochAt(roadIndex) if err != nil { - return types.EpochDuo{}, fmt.Errorf("epoch %d (road %d) not in registry", centerIdx, roadIndex) + return types.EpochDuo{}, err } prev := utils.None[*types.Epoch]() - if centerIdx > 0 { - p, err := r.EpochAt(FirstRoad(centerIdx - 1)) + if current.EpochIndex() > 0 { + p, err := r.EpochAt(current.RoadRange().First - 1) if err != nil { - return types.EpochDuo{}, fmt.Errorf("epoch %d prev (road %d) not in registry", centerIdx-1, roadIndex) + return types.EpochDuo{}, fmt.Errorf("DuoAt(%d): Prev epoch missing: %w", roadIndex, err) } prev = utils.Some(p) } return types.NewEpochDuo(current, prev), nil } +// WaitForEpoch blocks until epoch i is registered. +// Must not hold the avail/data inner lock (execution may seed via AdvanceIfNeeded). +func (r *Registry) WaitForEpoch(ctx context.Context, i types.EpochIndex) (*types.Epoch, error) { + for inner, ctrl := range r.state.Lock() { + for { + if ep, ok := inner[i]; ok { + return ep, nil + } + if err := ctrl.Wait(ctx); err != nil { + return nil, err + } + } + } + panic("unreachable") +} + // WaitForDuo blocks until DuoAt(roadIndex) succeeds. -// Waits on epochGen (any registration), so filling Prev after Current is -// already present still unblocks. Must not hold the avail/data inner lock -// (execution may seed via AdvanceIfNeeded). +// Must not hold the avail/data inner lock (execution may seed via AdvanceIfNeeded). func (r *Registry) WaitForDuo(ctx context.Context, roadIndex types.RoadIndex) (types.EpochDuo, error) { - sub := r.epochGen.Subscribe() - for { - // Capture gen before DuoAt so a registration between check and Wait still wakes. - seen := sub.Load() - if duo, err := r.DuoAt(roadIndex); err == nil { - return duo, nil - } - if _, err := sub.Wait(ctx, func(gen uint64) bool { return gen > seen }); err != nil { + i := IndexForRoad(roadIndex) + current, err := r.WaitForEpoch(ctx, i) + if err != nil { + return types.EpochDuo{}, err + } + prev := utils.None[*types.Epoch]() + if i > 0 { + p, err := r.WaitForEpoch(ctx, i-1) + if err != nil { return types.EpochDuo{}, err } + prev = utils.Some(p) } + return types.NewEpochDuo(current, prev), nil } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index caca180b01..c8b881930f 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -187,8 +187,7 @@ func TestDuoAt_ErrorWhenCurrentMissing(t *testing.T) { })) ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) bare := &Registry{ - state: utils.NewRWMutex(registryState{0: ep}), - epochGen: utils.NewAtomicSend(uint64(0)), + state: utils.NewWatch(registryState{0: ep}), } _, err := bare.DuoAt(FirstRoad(1)) if err == nil { @@ -204,8 +203,7 @@ func TestDuoAt_ErrorWhenPrevMissing(t *testing.T) { ep2 := types.NewEpoch(2, types.RoadRange{First: FirstRoad(2), Next: FirstRoad(3)}, committee) // Gap: epoch 2 present without epoch 1. bare := &Registry{ - state: utils.NewRWMutex(registryState{0: ep0, 2: ep2}), - epochGen: utils.NewAtomicSend(uint64(0)), + state: utils.NewWatch(registryState{0: ep0, 2: ep2}), } _, err := bare.DuoAt(FirstRoad(2)) if err == nil { diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index 4288dca6ed..65dbd71e88 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -12,7 +12,7 @@ import ( // EpochAtTip (or EpochAt) so View.EpochIndex matches the road's epoch when // GenRegistry starts away from genesis. func (r *Registry) LatestEpoch() *types.Epoch { - for s := range r.state.RLock() { + for s := range r.state.Lock() { var best types.EpochIndex var ep *types.Epoch for idx, e := range s { From a692ce15deb278ddb593665f87940bd19ee9876d Mon Sep 17 00:00:00 2001 From: Wen Date: Fri, 31 Jul 2026 20:17:21 -0700 Subject: [PATCH 85/85] docs(consensus): seal AppQC leash is on runAdvanceEpoch Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/consensus/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index d207d7cd75..d1b2212089 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -287,7 +287,7 @@ func (s *State) runPropose(ctx context.Context) error { return nil } // AppQC is optional on tipcuts: attach only when LastAppQC is newer and - // in-window (buildProposal). Seal AppQC leash is on CommitQC admission. + // in-window (buildProposal). Seal AppQC leash is on avail runAdvanceEpoch. fullProposal, err := types.NewProposal( s.cfg.Key, vs,