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 855348511e..530dd84e29 100644 --- a/sei-tendermint/AGENTS.md +++ b/sei-tendermint/AGENTS.md @@ -26,6 +26,7 @@ Within sei-tendermint subdirectory Required fields must be nil-checked at the boundary (constructor and proto Decode) and trusted everywhere else — do not add defensive nil-checks in internal logic. * Avoid removing comments and logs which are not obviously obsolete. Keep the original wording, only fixing mistakes or obsolete parts. +* Prefer concise comments that state invariants, contracts, and observable behavior (plus brief correctness reasoning when non-obvious). Avoid narrating how the implementation works step-by-step. * TestRng instance should be one per test, constructed directly in the test function. In case of nested/table tests, each nested test should create its own instance. Use TestRng.Split() (before spawning) if you need to pass entropy source to a spawned goroutine to ensure deterministic entropy across the goroutines. diff --git a/sei-tendermint/autobahn/types/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 2206e65e8b..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. @@ -231,12 +235,16 @@ func (m *Proposal) NextTimestamp() time.Time { } // Verify checks epoch binding, lane-range structural validity (bounds, max-length, -// and lane committee membership), and proposal-hash integrity. QC-chain continuity +// and lane committee membership). Empty tipcuts (no finalized blocks) are rejected; +// leaders wait for LaneQCs via WaitForLaneQCs instead. QC-chain continuity // (matching starts against the previous QC) is only enforced by FullProposal.Verify. func (m *Proposal) Verify(ep *Epoch) error { if err := m.view.Verify(ep); err != nil { return fmt.Errorf("view: %w", err) } + if m.globalRange.Len() == 0 { + return errors.New("empty tipcut: proposal must finalize at least one block") + } c := ep.Committee() for _, r := range m.laneRanges { if got := r.Len(); got > MaxLaneRangeInProposal { @@ -295,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()) } @@ -356,7 +364,11 @@ func buildProposal( if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { timestamp = wantMin } - return newProposal(viewSpec.View(), timestamp, laneRanges, app, viewSpec.NextGlobalBlock()), appQC, nil + proposal := newProposal(viewSpec.View(), timestamp, laneRanges, app, viewSpec.NextGlobalBlock()) + if proposal.GlobalRange().Len() == 0 { + return nil, appQC, errors.New("empty tipcut: need at least one LaneQC") + } + return proposal, appQC, nil } // NewProposalForTesting builds a FullProposal exactly like NewProposal but attaches the @@ -403,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 { @@ -432,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 @@ -451,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. @@ -490,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 { @@ -502,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 408ed5f9d6..b4f7491490 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -71,22 +71,33 @@ func makeAppQCFor(keys []SecretKey, globalNum GlobalBlockNumber, roadIdx RoadInd return NewAppQC(votes) } -func TestProposalVerifyFreshEmptyRanges(t *testing.T) { +func TestProposalVerifyRejectsEmptyTipcut(t *testing.T) { rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) + committee, _ := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} - proposerKey := leaderKey(committee, keys, vs.View()) + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) - require.NoError(t, fp.Verify(vs)) + // Direct Proposal.Verify rejects empty tipcuts (no LaneQCs / zero GlobalRange). + // Local propose waits via WaitForLaneQCs; verification must refuse empty tipcuts + // from peers as well. + empty := newProposal(vs.View(), time.Now(), nil, utils.None[*AppProposal](), vs.NextGlobalBlock()) + require.Equal(t, uint64(0), empty.GlobalRange().Len()) + require.Error(t, empty.Verify(ep)) +} + +// oneLaneQCMap builds a single LaneQC so NewProposal is non-empty (empty tipcuts +// are rejected by Proposal.Verify). +func oneLaneQCMap(rng utils.Rng, committee *Committee, keys []SecretKey, vs ViewSpec) map[LaneID]*LaneQC { + lane := committee.Lanes().At(0) + n := LaneRangeOpt(vs.CommitQC, lane).Next() + return map[LaneID]*LaneQC{lane: makeLaneQC(rng, committee, keys, lane, n, BlockHeaderHash{})} } func TestProposalVerifyFreshWithBlocks(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) // Produce a LaneQC for the proposer's lane. @@ -102,7 +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() @@ -122,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() @@ -145,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( @@ -170,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, nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) require.NoError(t, fp.Verify(vs)) vsLater := vs - vsLater.Epoch = NewEpoch(ep.EpochIndex(), ep.RoadRange(), fp.Proposal().Msg().Timestamp().Add(time.Nanosecond), committee, ep.FirstBlock()) + vsLater.Epochs.Current = NewEpoch(ep.EpochIndex(), ep.RoadRange(), fp.Proposal().Msg().Timestamp().Add(time.Nanosecond), committee, ep.FirstBlock()) require.Error(t, fp.Verify(vsLater)) }) @@ -184,7 +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)) @@ -202,14 +213,14 @@ 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( proposer1, vs1a, fp0a.Proposal().Msg().NextTimestamp(), - nil, + oneLaneQCMap(rng, committee, keys, vs1a), utils.None[*AppQC](), )) @@ -224,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(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) // Verify it against a different ViewSpec (view 1, 0). commitQC := makeCommitQCFromProposal(keys, fp) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epoch: ep} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC), Epochs: EpochDuo{Current: ep}} err := fp.Verify(vs1) require.Error(t, err) } @@ -239,12 +250,12 @@ 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. - fp1 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) - fp2 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now().Add(time.Hour), nil, utils.None[*AppQC]())) + fp1 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) + fp2 := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now().Add(time.Hour), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Graft fp1's signature onto fp2 (different content). fp2.proposal.sig = fp1.proposal.sig @@ -256,10 +267,10 @@ func TestProposalVerifyRejectsWrongProposer(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} correctLeader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Re-sign the same proposal with a different (non-leader) key. var wrongKey SecretKey @@ -283,10 +294,10 @@ func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} // no timeoutQC + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} // no timeoutQC proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Attach a timeoutQC that the ViewSpec doesn't expect. var timeoutVotes []*FullTimeoutVote @@ -309,10 +320,10 @@ func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Replace one committee lane with a non-committee lane. // E.g. committee = {A, B, C, D}, proposal = {A, B, C, X}. @@ -350,27 +361,30 @@ func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) - // Drop one lane — the omitted lane gets an implicit [0, 0) range, - // which matches the expected first=0 at genesis. + // Drop one empty lane — the omitted lane gets an implicit [0, 0) range, + // which matches the expected first=0 at genesis. Keep the non-empty range + // (and its LaneQC) so the tipcut is non-empty. origP := fp.Proposal().Msg() var keptRanges []*LaneRange - first := true + droppedEmpty := false for _, r := range origP.laneRanges { - if first { - first = false + if !droppedEmpty && r.Len() == 0 { + droppedEmpty = true continue } keptRanges = append(keptRanges, r) } + require.True(t, droppedEmpty) shortProposal := newProposal(origP.view, origP.timestamp, keptRanges, origP.app, origP.GlobalRange().First) shortFP := &FullProposal{ proposal: Sign(proposerKey, shortProposal), + laneQCs: fp.laneQCs, } require.NoError(t, shortFP.Verify(vs)) } @@ -379,25 +393,30 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) - // Keep only every other lane (e.g. {A, C} out of {A, B, C, D}). + // Drop every other empty lane (keep the non-empty range and its LaneQC). origP := fp.Proposal().Msg() var keptRanges []*LaneRange - i := 0 + emptyIdx := 0 for _, r := range origP.laneRanges { - if i%2 == 0 { - keptRanges = append(keptRanges, r) + if r.Len() == 0 { + if emptyIdx%2 == 0 { + emptyIdx++ + continue + } + emptyIdx++ } - i++ + keptRanges = append(keptRanges, r) } shortProposal := newProposal(origP.view, origP.timestamp, keptRanges, origP.app, origP.GlobalRange().First) shortFP := &FullProposal{ proposal: Sign(proposerKey, shortProposal), + laneQCs: fp.laneQCs, } require.NoError(t, shortFP.Verify(vs)) } @@ -406,10 +425,10 @@ func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Tamper: change one lane's first to 5 (genesis expects 0). origP := fp.Proposal().Msg() @@ -434,7 +453,7 @@ func TestProposalVerifyRejectsMissingLaneQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -456,7 +475,7 @@ func TestProposalVerifyRejectsLaneQCBlockNumberMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -480,7 +499,7 @@ func TestProposalVerifyRejectsInvalidLaneQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -541,7 +560,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(), @@ -571,13 +590,14 @@ func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { lQCs := map[LaneID]*LaneQC{l: makeLaneQC(rng, committee, keys, l, 0, GenBlockHeaderHash(rng))} commitQC0 := makeCommitQC(keys, makeFullProposal(ep, keys, utils.None[*CommitQC](), lQCs, utils.None[*AppQC]())) appQC0 := makeAppQCFor(keys, commitQC0.GlobalRange().First, 0, GenAppHash(rng), ep.EpochIndex()) - commitQC1a := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), nil, utils.Some(appQC0))) - commitQC1b := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), nil, utils.None[*AppQC]())) - fp2a := makeFullProposal(ep, keys, utils.Some(commitQC1a), nil, utils.None[*AppQC]()) - fp2b := makeFullProposal(ep, keys, utils.Some(commitQC1b), nil, utils.None[*AppQC]()) + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), 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), 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]()) // We construct the invalid proposal by constructing 2 alternative futures: one with appQC, one without. - vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epoch: ep} require.NoError(t, fp2a.Verify(vs)) require.Error(t, fp2b.Verify(vs)) } @@ -586,10 +606,10 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} // no previous commitQC, so app starts at None + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} // no previous commitQC, so app starts at None leader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.None[*AppQC]())) + 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()) @@ -604,49 +624,69 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { } func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) // firstBlock >= 1, so firstBlock-1 is valid - vs := ViewSpec{Epoch: ep} // no previous commitQC - leader := leaderKey(committee, keys, vs.View()) + for _, tc := range []struct { + name string + appEpoch EpochIndex + }{ + {"current", 1}, + {"prev", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + prev := NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1) + current := NewEpoch(1, OpenRoadRange(), time.Time{}, committee, 1) + vs := ViewSpec{Epochs: EpochDuo{Prev: utils.Some(prev), Current: current}} + leader := leaderKey(committee, keys, vs.View()) - // Build a valid proposal with an AppQC, then strip it. - goodAppQC := makeAppQCFor(keys, ep.FirstBlock()-1, 0, GenAppHash(rng), ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) + // Fresh AppQC: globalNumber 0 < FirstBlock 1. + goodAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) - tamperedFP := &FullProposal{ - proposal: fp.proposal, + tamperedFP := &FullProposal{proposal: fp.proposal} + require.Error(t, tamperedFP.Verify(vs)) + }) } - err := tamperedFP.Verify(vs) - require.Error(t, err) } func TestProposalVerifyRejectsAppQCMismatch(t *testing.T) { - rng := utils.TestRng() - committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} - leader := leaderKey(committee, keys, vs.View()) - - // Build a valid proposal with an AppQC, then swap in a different one. - goodAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(goodAppQC))) - - differentAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) - tamperedFP := &FullProposal{ - proposal: fp.proposal, - appQC: utils.Some(differentAppQC), + for _, tc := range []struct { + name string + appEpoch EpochIndex + }{ + {"current", 1}, + {"prev", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + prev := NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1) + current := NewEpoch(1, OpenRoadRange(), time.Time{}, committee, 1) + vs := ViewSpec{Epochs: EpochDuo{Prev: utils.Some(prev), Current: current}} + leader := leaderKey(committee, keys, vs.View()) + + goodAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) + + differentAppQC := makeAppQCFor(keys, 0, 0, GenAppHash(rng), tc.appEpoch) + tamperedFP := &FullProposal{ + proposal: fp.proposal, + appQC: utils.Some(differentAppQC), + } + require.Error(t, tamperedFP.Verify(vs)) + }) } - err := tamperedFP.Verify(vs) - require.Error(t, err) } func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - // firstBlock=1 so NextGlobalBlock()=1 and globalNumber=0 is a valid app target. - vs := ViewSpec{Epoch: NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1)} + // OpenRoadRange so View index 0 is valid for both epochs; this test only + // covers AppQC epoch resolution via ViewSpec.Epochs. + prev := NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1) + current := NewEpoch(1, OpenRoadRange(), time.Time{}, committee, 1) + vs := ViewSpec{Epochs: EpochDuo{Prev: utils.Some(prev), Current: current}} leader := leaderKey(committee, keys, vs.View()) makeAppQCWithEpoch := func(epochIdx EpochIndex) *AppQC { @@ -659,45 +699,59 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { return NewAppQC(votes) } - // app epoch matches proposal epoch — accepted. - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.Some(makeAppQCWithEpoch(0)))) + // AppQC from Current — accepted. + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), 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(), nil, 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(), nil, utils.Some(goodAppQC))) - - // Swap in an AppQC signed by NON-committee keys (same hash). - otherKeys := make([]SecretKey, len(keys)) - for i := range otherKeys { - otherKeys[i] = GenSecretKey(rng) - } - badAppQC := makeAppQCFor(otherKeys, ep.FirstBlock(), 0, appHash, ep.EpochIndex()) - tamperedFP := &FullProposal{ - proposal: fp.proposal, - appQC: utils.Some(badAppQC), + for _, tc := range []struct { + name string + appEpoch EpochIndex + }{ + {"current", 1}, + {"prev", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + prev := NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 1) + current := NewEpoch(1, OpenRoadRange(), time.Time{}, committee, 1) + vs := ViewSpec{Epochs: EpochDuo{Prev: utils.Some(prev), Current: current}} + leader := leaderKey(committee, keys, vs.View()) + + appHash := GenAppHash(rng) + goodAppQC := makeAppQCFor(keys, 0, 0, appHash, tc.appEpoch) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), 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, + appQC: utils.Some(badAppQC), + } + require.Error(t, tamperedFP.Verify(vs)) + }) } - err := tamperedFP.Verify(vs) - require.Error(t, err) } func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := ViewSpec{Epochs: EpochDuo{Current: ep}} proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -726,7 +780,7 @@ func TestProposalVerifyValidReproposal(t *testing.T) { // firstBlock > 0 ensures a reproposal bug that passes GlobalRange().First // (= sum(lane.First)+firstBlock) instead of firstBlock would be caught. ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} leader0 := leaderKey(committee, keys, vs0.View()) lane := committee.Leader(vs0.View()) laneQC0 := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -747,11 +801,11 @@ 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()) - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), oneLaneQCMap(rng, committee, keys, vs1), utils.None[*AppQC]())) // Reproposal must carry the same GlobalRange as the original. require.Equal(t, fp0.Proposal().Msg().GlobalRange(), reproposal.Proposal().Msg().GlobalRange()) @@ -764,9 +818,9 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} leader0 := leaderKey(committee, keys, vs0.View()) - fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) + fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) var prepareVotes []*Signed[*PrepareVote] for _, k := range keys { @@ -780,11 +834,11 @@ 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. - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), oneLaneQCMap(rng, committee, keys, vs1), utils.None[*AppQC]())) lane := keys[0].Public() laneQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -803,9 +857,9 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := ViewSpec{Epochs: EpochDuo{Current: ep}} leader0 := leaderKey(committee, keys, vs0.View()) - fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), nil, utils.None[*AppQC]())) + fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) var prepareVotes []*Signed[*PrepareVote] for _, k := range keys { @@ -819,11 +873,11 @@ 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. - reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), nil, utils.None[*AppQC]())) + reproposal := utils.OrPanic1(NewProposal(leader1, vs1, time.Now(), oneLaneQCMap(rng, committee, keys, vs1), utils.None[*AppQC]())) origP := reproposal.Proposal().Msg() var ranges []*LaneRange @@ -855,10 +909,10 @@ func TestProposalVerifyRejectsInvalidTimeoutQCSignature(t *testing.T) { } badTimeoutQC := NewTimeoutQC(timeoutVotes) - vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epoch: ep} + vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epochs: EpochDuo{Current: ep}} leader := leaderKey(committee, keys, vs.View()) - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), nil, utils.None[*AppQC]())) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) err := fp.Verify(vs) require.Error(t, err) @@ -870,17 +924,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 46ac55e314..d31972cd11 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -13,6 +13,8 @@ import ( ) // 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). // Use BuildFullCommitQC when you want random blocks generated automatically. func BuildCommitQC( epoch *Epoch, @@ -21,7 +23,10 @@ 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) + } leader := epoch.Committee().Leader(vs.View()) var leaderKey SecretKey for _, k := range keys { @@ -38,6 +43,20 @@ func BuildCommitQC( return NewCommitQC(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() + lane := c.Lanes().At(0) + n := LaneRangeOpt(vs.CommitQC, lane).Next() + header := NewBlock(lane, n, BlockHeaderHash{}, &Payload{}).Header() + vote := NewLaneVote(header) + votes := make([]*Signed[*LaneVote], 0, len(keys)) + for _, k := range TestKeysWithWeight(c, keys, c.LaneQuorum()) { + votes = append(votes, Sign(k, vote)) + } + return map[LaneID]*LaneQC{lane: NewLaneQC(votes)} +} + // GenNodeID generates a random NodeID. func GenNodeID(rng utils.Rng) NodeID { return NodeID(utils.GenString(rng, 10)) @@ -276,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, @@ -303,12 +322,15 @@ func GenProposalAt(rng utils.Rng, view View) *Proposal { return newProposal(view, utils.GenTimestamp(rng), utils.GenSlice(rng, GenLaneRange), utils.Some(GenAppProposal(rng)), GlobalBlockNumber(rng.Uint64())) } -// ProposalAt returns a minimal Proposal at view, consistent with ep. -// No lane ranges and no app proposal — only for tests that care about -// signature weight or epoch binding, not lane/app data. +// ProposalAt returns a minimal non-empty Proposal at view, consistent with ep. +// Includes a single 1-block lane range so Proposal.Verify accepts it (empty +// tipcuts are forbidden). For tests that care about signature weight or epoch +// binding rather than real lane/app data. func ProposalAt(ep *Epoch, view View) *Proposal { view.EpochIndex = ep.EpochIndex() - return newProposal(view, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) + lane := ep.Committee().Lanes().At(0) + header := NewBlock(lane, 0, BlockHeaderHash{}, &Payload{}).Header() + return newProposal(view, time.Time{}, []*LaneRange{NewLaneRange(lane, 0, utils.Some(header))}, utils.None[*AppProposal](), ep.FirstBlock()) } // GenProposalForEpoch generates a Proposal at a specific view whose epochIndex, 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 afdee71d9a..60b223e11b 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -14,7 +14,9 @@ import ( func TestPruneMismatchedIndices(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.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() @@ -23,7 +25,7 @@ func TestPruneMismatchedIndices(t *testing.T) { lqcs := map[types.LaneID]*types.LaneQC{ l: types.NewLaneQC(makeLaneVotes(keys, b.Header())), } - return makeCommitQC(registry.LatestEpoch(), keys, prev, lqcs, utils.None[*types.AppQC]()) + return makeCommitQC(ep, keys, prev, lqcs, utils.None[*types.AppQC]()) } makeAppQC := func(qcForRange *types.CommitQC, qcForIndex *types.CommitQC) *types.AppQC { gr := qcForRange.GlobalRange() @@ -39,25 +41,24 @@ func TestPruneMismatchedIndices(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - require.Error(t, state.PushAppQC(makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") - require.Error(t, state.PushAppQC(makeAppQC(qc1, qc0), qc1), "good range, bad index should fail") - require.Error(t, state.PushAppQC(makeAppQC(qc0, qc1), qc1), "bad range, good index should fail") - require.NoError(t, state.PushAppQC(makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc1, qc0), qc1), "good range, bad index should fail") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc0, qc1), qc1), "bad range, good index should fail") + require.NoError(t, state.PushAppQC(t.Context(), makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") t.Logf("test inner.prune") ds = utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) state, err = NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) for inner := range state.inner.Lock() { - _, err := inner.prune(registry.LatestEpoch().Committee(), makeAppQC(qc1, qc0), qc1) + _, err := inner.prune(makeAppQC(qc1, qc0), qc1) require.Error(t, err, "good range, bad index should fail") require.False(t, inner.latestAppQC.IsPresent(), "latestAppQC should not have been updated") - _, err = inner.prune(registry.LatestEpoch().Committee(), makeAppQC(qc1, qc1), qc1) + _, err = inner.prune(makeAppQC(qc1, qc1), qc1) require.NoError(t, err, "good range, good index should succeed") } } -// testSignedBlock creates a signed lane proposal for a given lane, block number, and parent hash. func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber, parent types.BlockHeaderHash, rng utils.Rng) *types.Signed[*types.LaneProposal] { block := types.NewBlock(lane, n, parent, types.GenPayload(rng)) return types.Sign(key, types.NewLaneProposal(block)) @@ -65,28 +66,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)) @@ -100,11 +101,11 @@ func TestDecodePruneAnchorIncomplete(t *testing.T) { func TestNewInnerLoadedNoAnchor(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) loaded := &loadedAvailState{} - i, err := newInner(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. @@ -113,9 +114,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. @@ -131,10 +145,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 { @@ -142,35 +156,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() @@ -180,11 +194,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) } @@ -193,7 +207,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane0 := keys[0].Public() lane1 := keys[1].Public() @@ -217,25 +231,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) @@ -254,7 +268,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. @@ -272,7 +286,7 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // AppQC at road index 2. roadIdx := types.RoadIndex(2) @@ -300,7 +314,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. @@ -324,7 +338,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { func TestNewInnerLoadedAllThree(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // AppQC at road index 2. @@ -361,7 +375,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. @@ -374,10 +388,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() @@ -387,10 +401,10 @@ func TestNewInnerLoadedAllThree(t *testing.T) { func TestPruneAdvancesNextBlockToPersist(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() - i, err := newInner(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. @@ -398,10 +412,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. @@ -409,7 +423,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()), @@ -430,23 +444,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, @@ -465,10 +479,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])) @@ -476,7 +490,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Simulate crash between anchor write and CommitQC file write: // anchor has AppQC@3 + CommitQC@3, but no CommitQC files on disk. @@ -494,10 +508,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])) @@ -510,13 +524,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]() @@ -537,20 +551,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) @@ -561,7 +575,7 @@ func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Simulate crash scenario: disk had stale QCs [0,1,2] and a new QC at // index 10. loadPersistedState pre-filters stale entries, so newInner @@ -585,7 +599,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. @@ -605,7 +619,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) // Build 6 CommitQCs (0-5). Anchor at index 3. // Loaded list includes stale entries [1, 2] below the anchor plus [3, 4, 5]. @@ -634,10 +648,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() @@ -647,10 +661,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]() @@ -673,14 +687,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 @@ -697,14 +711,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. @@ -725,14 +739,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. @@ -751,14 +765,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. @@ -794,20 +808,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. @@ -830,7 +844,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. @@ -838,3 +852,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 913d6fbfb4..f7552f503c 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, data.ErrPruned + } + q := ls.votes for i := range headers { n := lr.Next() - types.BlockNumber(i) - 1 //nolint:gosec // i is bounded by len(headers) which is a small block range; no overflow risk for { @@ -513,11 +717,9 @@ func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.Bloc if q.first > lr.First() { return nil, data.ErrPruned } - // Check if we have the header. - if entry, ok := q.q[n].byHash[want]; ok { - h := entry.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 data.ErrPruned when the CommitQC/headers were pruned, or when the +// road is behind the epoch duo (ErrRoadBeforeWindow) so the export loop can +// jump ahead. 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, data.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, data.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 3558c4a5ad..04ea7ebcf4 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -24,6 +24,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] { @@ -89,7 +102,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 { @@ -142,7 +155,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) } @@ -179,7 +192,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) } @@ -216,7 +229,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() @@ -263,7 +276,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) } @@ -321,7 +334,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() @@ -333,7 +346,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, @@ -372,15 +385,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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + timeout, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + require.ErrorIs(t, state.waitForAppQCEpoch(timeout, 0, 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 := utils.OrPanic1(data.NewState(&data.Config{ Registry: registry, @@ -405,7 +458,7 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { func TestPushBlockRejectsWrongSigner(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) ds := utils.OrPanic1(data.NewState(&data.Config{ Registry: registry, @@ -423,7 +476,8 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { func TestNewStateWithPersistence(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + // Road-0 CommitQC chains: pin genesis epoch so LatestEpoch matches roads. + registry, keys := epoch.GenRegistryAt(rng, 4, 0) initialBlock := types.GlobalBlockNumber(0) t.Run("empty dir loads fresh state", func(t *testing.T) { @@ -801,10 +855,13 @@ func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { } } - laneQCs, err := state.WaitForLaneQCs(ctx, ep, utils.None[*types.CommitQC]()) + laneQCs, gotEp, err := state.WaitForLaneQCs(ctx, utils.None[*types.CommitQC]()) if err != nil { return err } + if gotEp.EpochIndex() != ep.EpochIndex() { + return fmt.Errorf("WaitForLaneQCs returned epoch %d, want %d", gotEp.EpochIndex(), ep.EpochIndex()) + } for lane := range laneQCs { if !ep.Committee().HasLane(lane) { return fmt.Errorf("WaitForLaneQCs returned lane %v outside committee", lane) @@ -816,3 +873,625 @@ func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { return nil })) } + +func TestPushAppQCOutsideWindowDrops(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + lane := keys[0].Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block.Header()))}, + utils.None[*types.AppQC]()) + appQC := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + + // 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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // AppVote for epoch 1's first road: CommitQC tip is still 0, so PushAppVote + // blocks in waitForCommitQC (not on the epoch window). + ep1 := utils.OrPanic1(registry.EpochAt(epoch.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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = state.waitEpochForRoad(ctx, 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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 2) + + ep, err := state.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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err = state.waitCurrentForRoad(ctx, epoch.FirstRoad(1)) + require.ErrorIs(t, err, context.Canceled) +} + +func TestWaitCurrentForRoadStaleReturnsNone(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 2) + + ep, err := state.waitCurrentForRoad(t.Context(), 0) + require.NoError(t, err) + require.False(t, ep.IsPresent()) +} + +// TestWaitCurrentForRoadPrevNotAdmitted: a road in Prev is too late for +// Current-only admission (CommitQC), even though waitEpochForRoad would +// still resolve it. +func TestWaitCurrentForRoadPrevNotAdmitted(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) // Prev=0|Current=1 + + roadInPrev := types.RoadIndex(0) + epWin, err := state.waitEpochForRoad(t.Context(), roadInPrev) + require.NoError(t, err) + require.True(t, epWin.IsPresent(), "Prev|Current window still covers road 0") + + epCur, err := state.waitCurrentForRoad(t.Context(), roadInPrev) + require.NoError(t, err) + require.False(t, epCur.IsPresent(), "Current-only wait must treat Prev roads as too late") +} + +func TestPushCommitQCStaleDrops(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + lane := keys[0].Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block.Header()))}, + utils.None[*types.AppQC]()) + + registerDuoAtEpoch(state, 2) + + require.NoError(t, state.PushCommitQC(t.Context(), qc0)) + require.False(t, state.LastCommitQC().Load().IsPresent(), "stale CommitQC must not be admitted") +} + +// 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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + lane := keys[0].Public() + block := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block.Header()))}, + utils.None[*types.AppQC]()) + require.NoError(t, state.PushCommitQC(t.Context(), qc0)) + state.markCommitQCsPersisted(qc0) + + // Slide duo so road 0 is behind WindowFirst. + registerDuoAtEpoch(state, 2) + + _, _, err = state.fullCommitQC(t.Context(), 0) + require.ErrorIs(t, err, data.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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // Plant a CommitQC for epoch 1 while duo is still epoch 0. + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt( + utils.OrPanic1(registry.EpochAt(0)), types.View{EpochIndex: 0, Index: epoch.LastRoad(0)}))), + }) + lane := keys[0].Public() + block1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc1 := makeCommitQC(ep1, keys, utils.Some(prevTip), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block1.Header()))}, + utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(1), qc1.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commitQCs.first = epoch.FirstRoad(1) + inner.commitQCs.next = epoch.FirstRoad(1) + 1 + inner.commitQCs.q[epoch.FirstRoad(1)] = qc1 + } + state.markCommitQCsPersisted(qc1) + + _, _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(1)) + require.ErrorIs(t, err, types.ErrRoadAfterWindow) + require.NotErrorIs(t, err, data.ErrPruned) +} + +// 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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + + lane := keys[0].Public() + 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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + + lane := keys[0].Public() + // 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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + + lane := keys[0].Public() + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.LastRoad(1) - 1, + }))), + }) + blockLast := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qcLast := makeCommitQC(ep1, keys, utils.Some(prevOnLast), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockLast.Header()))}, + utils.None[*types.AppQC]()) + appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + require.Equal(t, epoch.LastRoad(1), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commitQCs.first = epoch.LastRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) + } + state.markCommitQCsPersisted(prevOnLast) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + // 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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + registry.AdvanceIfNeeded(epoch.LastRoad(0)) // epoch 2 for N+1 leash + + lane := keys[0].Public() + block0 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block0.Header()))}, + utils.None[*types.AppQC]()) + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.LastRoad(1) - 1, + }))), + }) + blockLast := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qcLast := makeCommitQC(ep1, keys, utils.Some(prevOnLast), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockLast.Header()))}, + utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(1), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC0) // only E-1 — not enough to close E + inner.commitQCs.first = epoch.LastRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) + } + state.markCommitQCsPersisted(prevOnLast) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushCommitQC(ctx, qcLast), context.Canceled) + + // AppQC in epoch 1 unlocks the boundary. + block1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc1 := makeCommitQC(ep1, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0), + }))), + })), map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block1.Header()))}, + utils.None[*types.AppQC]()) + appQC1 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc1.GlobalRange().First, qc1.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC1) + // Prune floor in E as live prune would after AppQC in E. + inner.commitQCs.first = epoch.FirstRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) + } + + require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(1)+1, inner.commitQCs.next) + require.Equal(t, types.EpochIndex(2), inner.epochDuo.Load().Current.EpochIndex()) + } +} + +// TestPushAppQCBoundaryIncomingAppQC: tipcut closing E may carry the first +// AppQC in E; count it as incoming before prune so catch-up does not deadlock. +func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, 1) + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + + lane := keys[0].Public() + block0 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, block0.Header()))}, + utils.None[*types.AppQC]()) + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qc0.GlobalRange().First, qc0.Index(), types.GenAppHash(rng), ep0.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.LastRoad(1) - 1, + }))), + }) + blockLast := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)) + qcLast := makeCommitQC(ep1, keys, utils.Some(prevOnLast), + map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(makeLaneVotes(keys, blockLast.Header()))}, + utils.None[*types.AppQC]()) + appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), ep1.EpochIndex()))) + + for inner := range state.inner.Lock() { + inner.latestAppQC = utils.Some(appQC0) // stale; incoming is appQCLast + inner.commitQCs.first = epoch.LastRoad(1) + inner.commitQCs.next = epoch.LastRoad(1) + } + state.markCommitQCsPersisted(prevOnLast) + + require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(1)+1, inner.commitQCs.next) + require.Equal(t, types.EpochIndex(2), inner.epochDuo.Load().Current.EpochIndex()) + } +} + +func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { + registry, keys := epoch.GenRegistryAt(utils.TestRng(), 4, 0) // {0,1}; Current starts at epoch 0 + ep0 := utils.OrPanic1(registry.EpochAt(0)) + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // Satisfy waitForCommitQC(FirstRoad(1)-1) without pushing EpochLength QCs. + // Current remains epoch 0, so FirstRoad(1) is too early for waitCurrentForRoad. + // Minimal tip/future QCs are enough: we cancel before Verify. + tipQC := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0), + }))), + }) + state.markCommitQCsPersisted(tipQC) + + qc1 := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{ + EpochIndex: 1, + Index: epoch.FirstRoad(1), + }))), + }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushCommitQC(ctx, qc1), context.Canceled) +} + +// TestPushAppQCPreviousEpoch verifies that a late AppQC whose road falls in +// epoch N-1 is accepted after Current has advanced to epoch N. Its committee +// is resolved from Prev (N-1), not Current (N). +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 := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // 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 f27fa30e4a..944bfaf8b5 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions.go @@ -47,10 +47,10 @@ func (r *LaneVotesRecv) RecvBatch(ctx context.Context) ([]*types.Signed[*types.L var batch []*types.BlockHeader for inner, ctrl := range r.state.inner.Lock() { for { - for lane, bq := range inner.blocks { - upperBound := min(bq.next, inner.nextBlockToPersist[lane]) - for i := max(bq.first, r.next[lane]); i < upperBound; i++ { - batch = append(batch, bq.q[i].Msg().Block().Header()) + for lane, ls := range inner.lanes { + upperBound := min(ls.blocks.next, ls.nextBlockToPersist) + for i := max(ls.blocks.first, r.next[lane]); i < upperBound; i++ { + batch = append(batch, ls.blocks.q[i].Msg().Block().Header()) } r.next[lane] = upperBound } diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index f17a9f814c..500c779055 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -78,7 +78,7 @@ func RunTestNetwork(ctx context.Context, states []*State) error { } next = appQC.Next() for _, to := range states { - if err := to.PushAppQC(appQC, commitQC); err != nil { + if err := to.PushAppQC(ctx, appQC, commitQC); err != nil { return err } } diff --git a/sei-tendermint/internal/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 6625771c79..01f7598af6 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -48,7 +48,7 @@ func makePrepareQC(keys []types.SecretKey, proposal *types.Proposal) *types.Prep func TestNewInnerEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 1) + registry, _, _ := epoch.GenRegistry(rng, 1) // No data should return empty inner (persistence disabled / fresh start) i, err := newInner(utils.None[*pb.PersistedInner](), registry) require.NoError(t, err) @@ -62,9 +62,9 @@ func TestNewInnerPrepareVote(t *testing.T) { dir := t.TempDir() // Create and persist a prepare vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) vote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, &persistedInner{ @@ -84,9 +84,9 @@ func TestNewInnerCommitVote(t *testing.T) { dir := t.TempDir() // Create and persist a commit vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) vote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -108,7 +108,7 @@ func TestNewInnerTimeoutVote(t *testing.T) { dir := t.TempDir() // Create and persist a timeout vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] vote := types.NewFullTimeoutVote(key, types.View{Index: 0, Number: 0}, utils.None[*types.PrepareQC]()) @@ -129,9 +129,9 @@ func TestNewInnerAllVotes(t *testing.T) { dir := t.TempDir() // Create all vote types at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) commitVote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -157,9 +157,9 @@ func TestNewInnerPartialState(t *testing.T) { dir := t.TempDir() // Only persist prepareVote - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, &persistedInner{ @@ -177,10 +177,10 @@ func TestNewInnerPartialState(t *testing.T) { func TestNewInnerCommitQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create a CommitQC at index 5 - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -206,10 +206,10 @@ func TestNewInnerCommitQC(t *testing.T) { func TestNewInnerTimeoutQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create a CommitQC at index 5 (required for TimeoutQC at index 6) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -240,7 +240,7 @@ func TestNewInnerTimeoutQC(t *testing.T) { func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create TimeoutQC at (0, 2) - no CommitQC needed for index 0 var timeoutVotes []*types.FullTimeoutVote @@ -263,7 +263,7 @@ func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create TimeoutQC at index 6 WITHOUT CommitQC at index 5 var timeoutVotes []*types.FullTimeoutVote @@ -285,10 +285,10 @@ func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -317,10 +317,10 @@ func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 10 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -350,10 +350,10 @@ func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { func TestNewInnerViewSpecValidBothQCs(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -385,10 +385,10 @@ func TestNewInnerViewSpecValidBothQCs(t *testing.T) { func TestNewInnerStaleVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -398,7 +398,7 @@ func TestNewInnerStaleVoteError(t *testing.T) { // Create stale vote at view (3, 0) - before current view (6, 0). // Since inner is persisted atomically, a mismatched view is corrupt. - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) staleVote := types.Sign(keys[0], types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, &persistedInner{ @@ -414,10 +414,10 @@ func TestNewInnerStaleVoteError(t *testing.T) { func TestNewInnerFuturePrepareVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -426,7 +426,7 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future vote at view (10, 0) - ahead of current view (6, 0) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewPrepareVote(futureProposal)) seedPersistedInner(dir, &persistedInner{ @@ -443,10 +443,10 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { func TestNewInnerFutureCommitVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -455,7 +455,7 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future commit vote at view (10, 0) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewCommitVote(futureProposal)) seedPersistedInner(dir, &persistedInner{ @@ -472,10 +472,10 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { func TestNewInnerFutureTimeoutVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -500,10 +500,10 @@ func TestNewInnerFutureTimeoutVoteError(t *testing.T) { func TestNewInnerCurrentViewVoteOk(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -512,7 +512,7 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create vote at exactly current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) currentVote := types.Sign(keys[0], types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, &persistedInner{ @@ -529,14 +529,14 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) // Create CommitQC signed by keys NOT in committee otherKeys := make([]types.SecretKey, 3) for i := range otherKeys { otherKeys[i] = types.GenSecretKey(rng) } - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range otherKeys { @@ -557,10 +557,10 @@ func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -593,10 +593,10 @@ func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -606,7 +606,7 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { // Create vote at current view (6, 0) but signed by key NOT in committee otherKey := types.GenSecretKey(rng) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, &persistedInner{ @@ -623,10 +623,10 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -637,7 +637,7 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { // Create stale vote at (3, 0) signed by key NOT in committee. // Since inner is persisted atomically, a mismatched view is corrupt. otherKey := types.GenSecretKey(rng) - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, &persistedInner{ @@ -653,10 +653,10 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { func TestNewInnerPrepareQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create prepareQC at genesis view (0, 0) - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC(keys, proposal) seedPersistedInner(dir, &persistedInner{ @@ -672,10 +672,10 @@ func TestNewInnerPrepareQC(t *testing.T) { func TestNewInnerStalePrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -685,7 +685,7 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { // Create stale prepareQC at view (3, 0) - before current view (6, 0). // Since inner is persisted atomically, a mismatched view is corrupt. - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) stalePrepareQC := makePrepareQC(keys, staleProposal) seedPersistedInner(dir, &persistedInner{ @@ -701,11 +701,11 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Current view is (0, 0) (no CommitQC or TimeoutQC). // CommitVote requires PrepareQC justification. - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) commitVote := types.Sign(keys[0], types.NewCommitVote(proposal)) seedPersistedInner(dir, &persistedInner{ @@ -720,10 +720,10 @@ func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { func TestNewInnerFuturePrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -732,7 +732,7 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future prepareQC at index 10 (> current 6) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) prepareQC := makePrepareQC(keys, futureProposal) seedPersistedInner(dir, &persistedInner{ @@ -749,10 +749,10 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -761,7 +761,7 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -778,10 +778,10 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -794,7 +794,7 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { for i := range otherKeys { otherKeys[i] = types.GenSecretKey(rng) } - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(otherKeys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -811,11 +811,11 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) voteKey := keys[0] // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -824,7 +824,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -842,7 +842,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { timeoutVote := types.NewFullTimeoutVote(voteKey, currentView, i.PrepareQC) // The timeoutVote should pass verification (which checks prepareQC is correctly included) - err = timeoutVote.Verify(registry.LatestEpoch()) + err = timeoutVote.Verify(utils.OrPanic1(registry.EpochAt(0))) require.NoError(t, err, "timeoutVote with loaded prepareQC should verify") // Verify the loaded prepareQC matches what we persisted @@ -855,62 +855,63 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { // Test that pushTimeoutQC clears stale votes and prepareQC func TestPushTimeoutQCClearsStaleState(t *testing.T) { rng := utils.TestRng() - dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) - // Setup: Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) - qcVote := types.NewCommitVote(qcProposal) + // CommitQC at index 5 -> current view is (6, 0). + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { - qcVotes = append(qcVotes, types.Sign(k, qcVote)) + qcVotes = append(qcVotes, types.Sign(k, types.NewCommitVote(qcProposal))) } commitQC := types.NewCommitQC(qcVotes) - // Setup: Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + // prepareQC and votes at current view (6, 0). + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) - - // Setup: Create votes at current view (6, 0) prepareVote := types.Sign(keys[0], types.NewPrepareVote(currentProposal)) commitVote := types.Sign(keys[0], types.NewCommitVote(currentProposal)) timeoutVote := types.NewFullTimeoutVote(keys[0], types.View{Index: 6, Number: 0}, utils.Some(prepareQC)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareQC: utils.Some(prepareQC), - PrepareVote: utils.Some(prepareVote), - CommitVote: utils.Some(commitVote), - TimeoutVote: utils.Some(timeoutVote), - }) - - // Load initial state and verify everything is present - i, err := loadInner(dir, registry) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) + cs, err := newState(&Config{ + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + }, ds, utils.None[persist.Persister[*pb.PersistedInner]](), utils.None[*pb.PersistedInner]()) require.NoError(t, err) - require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") - require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be loaded") - require.True(t, i.CommitVote.IsPresent(), "commitVote should be loaded") - require.True(t, i.TimeoutVote.IsPresent(), "timeoutVote should be loaded") + + // Seed CommitQC so the state is at view (6, 0). + require.NoError(t, cs.pushCommitQC(commitQC)) + + // Inject per-view state directly so we can verify it gets cleared. + for isend := range cs.inner.Lock() { + i := isend.Load() + i.PrepareQC = utils.Some(prepareQC) + i.PrepareVote = utils.Some(prepareVote) + i.CommitVote = utils.Some(commitVote) + i.TimeoutVote = utils.Some(timeoutVote) + isend.Store(i) + } + + i := cs.innerRecv.Load() + require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be present before pushTimeoutQC") require.Equal(t, types.View{Index: 6, Number: 0}, i.View(), "initial view should be (6, 0)") - // Create a TimeoutQC for current view (6, 0) that advances to (6, 1) + // TimeoutQC for (6, 0) advances to (6, 1). var timeoutVotes []*types.FullTimeoutVote for _, k := range keys { timeoutVotes = append(timeoutVotes, types.NewFullTimeoutVote(k, types.View{Index: 6, Number: 0}, utils.Some(prepareQC))) } timeoutQC := types.NewTimeoutQC(timeoutVotes) - // Simulate pushTimeoutQC's Update callback - newInner := inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(timeoutQC)}, epoch: i.epoch} - - // Verify: view advanced to (6, 1) - require.Equal(t, types.View{Index: 6, Number: 1}, newInner.View(), "view should advance to (6, 1)") + ctx := t.Context() + require.NoError(t, cs.pushTimeoutQC(ctx, timeoutQC)) - // Verify: prepareQC and all votes are cleared (they're for old view) - require.False(t, newInner.PrepareQC.IsPresent(), "prepareQC should be cleared") - require.False(t, newInner.PrepareVote.IsPresent(), "prepareVote should be cleared") - require.False(t, newInner.CommitVote.IsPresent(), "commitVote should be cleared") - require.False(t, newInner.TimeoutVote.IsPresent(), "timeoutVote should be cleared") + i = cs.innerRecv.Load() + require.Equal(t, types.View{Index: 6, Number: 1}, i.View(), "view should advance to (6, 1)") + require.False(t, i.PrepareQC.IsPresent(), "prepareQC should be cleared") + require.False(t, i.PrepareVote.IsPresent(), "prepareVote should be cleared") + require.False(t, i.CommitVote.IsPresent(), "commitVote should be cleared") + require.False(t, i.TimeoutVote.IsPresent(), "timeoutVote should be cleared") } // failPersister is a Persister that always returns an error. @@ -922,7 +923,7 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { // Verify that a persist error in runOutputs propagates // and terminates the consensus component (instead of panicking). rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())))) wantErr := errors.New("disk on fire") diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index a1a0ae5035..2d9228aad1 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -22,28 +22,8 @@ func testCommitQC( laneQCs map[types.LaneID]*types.LaneQC, appQC utils.Option[*types.AppQC], ) *types.CommitQC { - vs := types.ViewSpec{CommitQC: prev, Epoch: types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, 0)} - leader := committee.Leader(vs.View()) - var leaderKey types.SecretKey - for _, k := range keys { - if k.Public() == leader { - leaderKey = k - break - } - } - fullProposal := utils.OrPanic1(types.NewProposal( - leaderKey, - vs, - time.Now(), - laneQCs, - appQC, - )) - vote := types.NewCommitVote(fullProposal.Proposal().Msg()) - var votes []*types.Signed[*types.CommitVote] - for _, k := range keys { - votes = append(votes, types.Sign(k, vote)) - } - return types.NewCommitQC(votes) + ep := types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, 0) + return types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) } func makeSequentialCommitQCs( @@ -106,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() @@ -135,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() @@ -158,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() @@ -186,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() @@ -204,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() @@ -223,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() @@ -247,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) @@ -281,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() @@ -313,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() @@ -357,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() @@ -396,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() @@ -422,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() @@ -451,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/persist/fullcommitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go index 5d21fa5046..048b32ed51 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go @@ -18,8 +18,8 @@ func makeSequentialFullCommitQCs( qcs := make([]*types.FullCommitQC, n) prev := utils.None[*types.CommitQC]() for i := range n { - vs := types.ViewSpec{CommitQC: prev, Epoch: registry.LatestEpoch()} - committee := vs.Epoch.Committee() + vs := types.ViewSpec{CommitQC: prev, Epochs: types.EpochDuo{Current: registry.LatestEpoch()}} + committee := vs.Epoch().Committee() lane := committee.Lanes().At(rng.Intn(committee.Lanes().Len())) b := types.NewBlock(lane, types.LaneRangeOpt(prev, lane).Next(), types.GenBlockHeaderHash(rng), types.GenPayload(rng)) lv := types.NewLaneVote(b.Header()) @@ -28,7 +28,7 @@ func makeSequentialFullCommitQCs( lvotes = append(lvotes, types.Sign(k, lv)) } laneQCs := map[types.LaneID]*types.LaneQC{lane: types.NewLaneQC(lvotes)} - cqc := types.BuildCommitQC(vs.Epoch, keys, prev, laneQCs, utils.None[*types.AppQC]()) + cqc := types.BuildCommitQC(vs.Epoch(), keys, prev, laneQCs, utils.None[*types.AppQC]()) qcs[i] = types.NewFullCommitQC(cqc, []*types.BlockHeader{b.Header()}) prev = utils.Some(qcs[i].QC()) } @@ -47,7 +47,7 @@ func TestNewFullCommitQCPersisterEmptyDir(t *testing.T) { func TestNewFullCommitQCPersisterNoop(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) gp, err := NewFullCommitQCPersister(utils.None[string](), registry.FirstBlock()) @@ -71,7 +71,7 @@ func TestNewFullCommitQCPersisterNoop(t *testing.T) { func TestFullCommitQCPersisterFirstBlockFromWAL(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) wantFirst := qcs[0].QC().GlobalRange().First @@ -92,7 +92,7 @@ func TestFullCommitQCPersisterFirstBlockFromWAL(t *testing.T) { func TestFullCommitQCPersistAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -118,7 +118,7 @@ func TestFullCommitQCPersistAndReload(t *testing.T) { func TestFullCommitQCTruncateAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -145,7 +145,7 @@ func TestFullCommitQCTruncateAndReload(t *testing.T) { func TestFullCommitQCTruncateAll(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -166,7 +166,7 @@ func TestFullCommitQCTruncateAll(t *testing.T) { func TestFullCommitQCDuplicateIgnored(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 2) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -180,7 +180,7 @@ func TestFullCommitQCDuplicateIgnored(t *testing.T) { func TestFullCommitQCGapError(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -195,7 +195,7 @@ func TestFullCommitQCGapError(t *testing.T) { func TestFullCommitQCTruncateBeforeNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 3) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -216,7 +216,7 @@ func TestFullCommitQCTruncateBeforeNoop(t *testing.T) { func TestFullCommitQCContinueAfterReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 6) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) @@ -244,7 +244,7 @@ func TestFullCommitQCContinueAfterReload(t *testing.T) { func TestFullCommitQCTruncateMidRange(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) qcs := makeSequentialFullCommitQCs(rng, registry, keys, 5) gp, err := NewFullCommitQCPersister(utils.Some(dir), registry.FirstBlock()) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go index 7a16453364..30a0bc0a7e 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go @@ -29,7 +29,7 @@ func TestNewGlobalBlockPersisterEmptyDir(t *testing.T) { func TestNewGlobalBlockPersisterNoop(t *testing.T) { rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -78,7 +78,7 @@ func TestGlobalBlockPersisterFirstBlockFromWAL(t *testing.T) { func TestGlobalBlockPersistAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -104,7 +104,7 @@ func TestGlobalBlockPersistAndReload(t *testing.T) { func TestGlobalBlockTruncateAndReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 10) @@ -129,7 +129,7 @@ func TestGlobalBlockTruncateAndReload(t *testing.T) { func TestGlobalBlockTruncateAll(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -152,7 +152,7 @@ func TestGlobalBlockTruncateAll(t *testing.T) { func TestGlobalBlockDuplicateIgnored(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) block := types.GenBlock(rng) @@ -167,7 +167,7 @@ func TestGlobalBlockDuplicateIgnored(t *testing.T) { func TestGlobalBlockGapError(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) block := types.GenBlock(rng) @@ -182,7 +182,7 @@ func TestGlobalBlockGapError(t *testing.T) { func TestGlobalBlockTruncateBeforeNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -204,7 +204,7 @@ func TestGlobalBlockTruncateBeforeNoop(t *testing.T) { func TestGlobalBlockContinueAfterReload(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 10) @@ -233,7 +233,7 @@ func TestGlobalBlockContinueAfterReload(t *testing.T) { func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) @@ -269,7 +269,7 @@ func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { func TestGlobalBlockTruncateAfterNoop(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 3) @@ -287,7 +287,7 @@ func TestGlobalBlockTruncateAfterNoop(t *testing.T) { func TestGlobalBlockTruncateAfterBeforeFirst(t *testing.T) { dir := t.TempDir() rng := utils.TestRng() - _, _ = epoch.GenRegistry(rng, 3) + _, _, _ = epoch.GenRegistry(rng, 3) fb := types.GlobalBlockNumber(0) blocks := makeGlobalBlocks(rng, 5) diff --git a/sei-tendermint/internal/autobahn/consensus/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 a4ede7d420..f9a5485024 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -7,8 +7,10 @@ import ( "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -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 := utils.OrPanic1(data.NewState( &data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), @@ -86,7 +88,7 @@ func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) - pqc := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0})) + pqc := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0})) if err := s.pushPrepareQC(ctx, pqc); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -116,7 +118,7 @@ func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { // View (0, 0): push PrepareQC for proposal P. view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -173,7 +175,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // View (0, 0): PrepareQC for P. view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC(pqc0): %w", err) } @@ -186,7 +188,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // Reproposal at (0, 1) succeeds — new PrepareQC at view (0, 1). view1 := types.View{Index: 0, Number: 1} - pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view1)) + pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC(pqc1): %w", err) } @@ -230,7 +232,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { // Fresh PrepareQC at (0, 1). view1 := types.View{Index: 0, Number: 1} - pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view1)) + pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -259,7 +261,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { // voteTimeout still inherits the PrepareQC from the persisted TimeoutQC. func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() makeCfg := func() *Config { @@ -277,7 +279,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { } view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) // Session 1: push PrepareQC + TimeoutQC, let runOutputs persist. err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { @@ -327,3 +329,27 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { }) require.NoError(t, err) } + +func TestNewState_AvailBehindConsensus(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 3) + ds := utils.OrPanic1(data.NewState( + &data.Config{Registry: registry}, + utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), + )) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + proposal := types.GenProposalForEpoch(rng, ep0, types.View{Index: 0, Number: 0}) + votes := make([]*types.Signed[*types.CommitVote], len(keys)) + for i, k := range keys { + votes[i] = types.Sign(k, types.NewCommitVote(proposal)) + } + // Consensus tipcut 1, fresh avail tipcut 0 → avail behind consensus. + _, err := newState(&Config{ + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + PersistentStateDir: utils.None[string](), + }, ds, utils.None[persist.Persister[*pb.PersistedInner]](), utils.Some(innerProtoConv.Encode(&persistedInner{ + CommitQC: utils.Some(types.NewCommitQC(votes)), + }))) + require.ErrorIs(t, err, ErrAvailBehindConsensus) +} diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 6311c20c05..59fc567866 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -28,6 +28,13 @@ type Config struct { Registry *epoch.Registry // PruneAfter is the duration after which the state prunes executed blocks. PruneAfter utils.Option[time.Duration] + // LastExecutedBlock is the last app-committed global height + // (app.LastBlockHeight). Used only to map → CommitQC road for + // SetupInitialDuo. 0 means fresh / unknown. + // + // TODO(autobahn): This is read from the Cosmos app state DB today. Autobahn + // should not depend on the app DB — move executed height into Giga storage. + LastExecutedBlock types.GlobalBlockNumber } // StateAPI is the interface of the State for consuming global blocks @@ -160,6 +167,9 @@ type inner struct { // RetainHeight pruning, making this in-memory index obsolete. blockHashes map[types.BlockHeaderHash]types.GlobalBlockNumber + // Store under Watch.Lock; readers use State.epochDuo. + epochDuo utils.AtomicSend[types.EpochDuo] + // first <= nextAppProposal <= nextBlockToPersist <= nextBlock <= nextQC // // This invariant guarantees no race between pruning and persisting: @@ -202,12 +212,8 @@ func (i *inner) skipTo(n types.GlobalBlockNumber) { // insertQC verifies and inserts a FullCommitQC into the inner state. // Accepts QCs whose range starts at or before nextQC (partially pruned // prefix is silently skipped). Rejects gaps where gr.First > nextQC. -func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error { - e, ok := registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) - } - if err := qc.Verify(e); err != nil { +func (i *inner) insertQC(qc *types.FullCommitQC, ep *types.Epoch) error { + if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } gr := qc.QC().GlobalRange() @@ -224,7 +230,7 @@ func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error return nil } -// insertBlock inserts a pre-verified block into the inner state. +// insertBlock inserts a block into the inner state. // Requires a QC to already be present for block n. Callers must verify // the block signature before calling. // @@ -232,7 +238,7 @@ func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error // updateNextBlock after inserting one or more blocks. This separation // allows batch insertion (e.g. PushQC inserts multiple blocks, then // advances nextBlock once). -func (i *inner) insertBlock(committee *types.Committee, n types.GlobalBlockNumber, block *types.Block) error { +func (i *inner) insertBlock(n types.GlobalBlockNumber, block *types.Block) error { if n < i.first || n >= i.nextQC { return nil // outside QC range } @@ -280,10 +286,11 @@ func (i *inner) pruneFirst(now time.Time, m *metrics.Metrics) { // State of the chain. // Contains blocks in global order and proofs of their finality. type State struct { - cfg *Config - metrics *metrics.Metrics - inner utils.Watch[*inner] - dataWAL *DataWAL + cfg *Config + metrics *metrics.Metrics + inner utils.Watch[*inner] + epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epochDuo; EvmProxy reads via EpochDuo() + dataWAL *DataWAL } // NewState constructs a new data State. @@ -301,10 +308,39 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { if dataFirst > cfg.Registry.FirstBlock() { inner.skipTo(dataFirst) } + // Seed epochs once here (see epoch.Registry tip-interlock: data is restart + // anchor). Seed {firstLoaded..tip} so every retained CommitQC has an epoch — + // stand-in for reading committee info from blocks once that is on-chain. + // Execution tip only adds AdvanceIfNeeded lookahead via EnsureAfterExecuted; + // if LastExecutedBlock's QC was pruned from the WAL, roadForGlobal is None + // and that lookahead is skipped — live AdvanceIfNeeded re-seeds forward. + // + // TODO(autobahn): LastExecutedBlock comes from app.LastBlockHeight() (app + // state DB). Move execution tip into Giga storage; stop reading the app DB. + loadedQCs := dataWAL.CommitQCs.ConsumeLoaded() + commitQCs := utils.None[epoch.CommitQCSpan]() + if n := len(loadedQCs); n > 0 { + commitQCs = utils.Some(epoch.CommitQCSpan{ + First: loadedQCs[0].QC().Proposal().Index(), + Last: loadedQCs[n-1].QC().Proposal().Index(), + }) + } + lastExecuted := roadForGlobal(loadedQCs, cfg.LastExecutedBlock) + cfg.Registry.SetupInitialDuo(lastExecuted, commitQCs) // Restore QCs. insertQC handles partially pruned QCs (range starts // before inner.first) by skipping the pruned prefix. - for _, qc := range dataWAL.CommitQCs.ConsumeLoaded() { - if err := inner.insertQC(cfg.Registry, qc); err != nil { + // + // TODO(autobahn): each QC needs Registry.EpochAt because SetupInitialDuo + // pre-seeded placeholders for the whole WAL CommitQC span. Once epoch / + // committee info lives on the block, proposal, or QC, avail/consensus + // persisted tips can restore independently and this per-CommitQC seeding + // / lookup on restart goes away. + for _, qc := range loadedQCs { + ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) + if err != nil { + return nil, fmt.Errorf("load QC from WAL: epoch lookup: %w", err) + } + if err := inner.insertQC(qc, ep); err != nil { return nil, fmt.Errorf("load QC from WAL: %w", err) } } @@ -319,15 +355,14 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { } expectedBlock = lb.Number + 1 qc := inner.qcs[lb.Number] - e, ok := cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return nil, fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) + ep, err := cfg.Registry.EpochAt(qc.QC().Proposal().Index()) + if err != nil { + return nil, fmt.Errorf("load block %d from WAL: epoch lookup: %w", lb.Number, err) } - committee := e.Committee() - if err := lb.Block.Verify(committee); err != nil { - return nil, fmt.Errorf("load block %d from WAL: %w", lb.Number, err) + if err := lb.Block.Verify(ep.Committee()); err != nil { + return nil, fmt.Errorf("load block %d from WAL: verify: %w", lb.Number, err) } - if err := inner.insertBlock(committee, lb.Number, lb.Block); err != nil { + if err := inner.insertBlock(lb.Number, lb.Block); err != nil { return nil, fmt.Errorf("load block %d from WAL: %w", lb.Number, err) } } @@ -343,25 +378,78 @@ func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { return nil, fmt.Errorf("blocks WAL cursor %d behind inner.nextBlock %d after reconciliation", dataWAL.Blocks.Next(), inner.nextBlock) } + initRoad := types.RoadIndex(0) + if inner.nextQC > 0 { + if lastQC := inner.qcs[inner.nextQC-1]; lastQC != nil { + // Tipcut road (Index+1): matches live PushQC after a boundary store. + initRoad = lastQC.QC().Proposal().Index() + 1 + } + } + initDuo, err := cfg.Registry.DuoAt(initRoad) + if err != nil { + return nil, fmt.Errorf("init epochDuo: %w", err) + } + inner.epochDuo = utils.NewAtomicSend(initDuo) return &State{ - cfg: cfg, - metrics: metrics.Get(), - inner: utils.NewWatch(inner), - dataWAL: dataWAL, + cfg: cfg, + metrics: metrics.Get(), + inner: utils.NewWatch(inner), + epochDuo: inner.epochDuo.Subscribe(), + dataWAL: dataWAL, }, nil } +// roadForGlobal returns the CommitQC road covering global block n, if present +// in the loaded WAL QCs. None when n==0 or the QC was pruned / not loaded +// (SetupInitialDuo then skips EnsureAfterExecuted; CommitQC tip still anchors). +func roadForGlobal(qcs []*types.FullCommitQC, n types.GlobalBlockNumber) utils.Option[types.RoadIndex] { + if n == 0 { + return utils.None[types.RoadIndex]() + } + for i := len(qcs) - 1; i >= 0; i-- { + if qcs[i].QC().GlobalRange().Has(n) { + return utils.Some(qcs[i].QC().Proposal().Index()) + } + } + return utils.None[types.RoadIndex]() +} + // Registry returns the epoch registry. func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } +// EpochDuo is a point-in-time snapshot; re-call after a boundary advance. +func (s *State) EpochDuo() types.EpochDuo { return s.epochDuo.Load() } + +// CommitTipCut is the road after the last applied CommitQC (Index+1), or 0 if +// none. Restart anchor for p2p.checkRestartTips (consensus tip vs data). +func (s *State) CommitTipCut() types.RoadIndex { + for inner := range s.inner.Lock() { + if inner.nextQC == 0 || inner.nextQC <= inner.first { + return 0 + } + qc := inner.qcs[inner.nextQC-1] + if qc == nil { + return 0 + } + return qc.QC().Proposal().Index() + 1 + } + panic("unreachable") +} + // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. +// +// 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) { @@ -384,6 +472,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 { @@ -392,13 +481,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 { @@ -408,13 +515,8 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty for n := max(inner.nextBlock, gr.First); n < min(gr.Next, inner.nextQC); n += 1 { storedQC := inner.qcs[n] storedGR := storedQC.QC().GlobalRange() - storedEp, ok := s.cfg.Registry.EpochByIndex(storedQC.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", storedQC.QC().Proposal().EpochIndex()) - } - storedCommittee := storedEp.Committee() if b, ok := byHash[storedQC.Headers()[n-storedGR.First].Hash()]; ok { - if err := inner.insertBlock(storedCommittee, n, b); err != nil { + if err := inner.insertBlock(n, b); err != nil { return err } } @@ -443,8 +545,11 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC // PushBlock pushes block to the state. // The QC for n must already be present (guaranteed by PushQC ordering). +// +// Same epochDuo admission as PushQC: pruned heights drop; before-window hard-fails. +// Do not fall back to Registry — see PushQC. func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block *types.Block) error { - var epochIdx types.EpochIndex + var ep *types.Epoch for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { return n < inner.nextQC }); err != nil { return err @@ -453,11 +558,11 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block // Block arrived after pruning; drop silently so the sender keeps delivering future blocks. return nil } - epochIdx = inner.qcs[n].QC().Proposal().EpochIndex() - } - ep, ok := s.cfg.Registry.EpochByIndex(epochIdx) - if !ok { - return fmt.Errorf("unknown epoch_index %d", epochIdx) + var err error + ep, err = s.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 { @@ -467,7 +572,7 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block if n < inner.first { return nil } - if err := inner.insertBlock(ep.Committee(), n, block); err != nil { + if err := inner.insertBlock(n, block); err != nil { return err } inner.updateNextBlock(s.metrics) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index a25e4d61aa..b3291866b3 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -51,7 +51,7 @@ func snapshot(s *State) Snapshot { func TestState(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -125,7 +125,7 @@ func TestState(t *testing.T) { func TestPushConflictingBadCommitQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) committee := registry.LatestEpoch().Committee() state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -172,7 +172,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { malBlocks = append(malBlocks, b) } } - viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: registry.LatestEpoch()} + viewSpec := types.ViewSpec{CommitQC: utils.None[*types.CommitQC](), Epochs: types.EpochDuo{Current: registry.LatestEpoch()}} leader := committee.Leader(viewSpec.View()) var leaderKey types.SecretKey for _, k := range keys { @@ -236,7 +236,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := utils.OrPanic1(NewState(&Config{ Registry: registry, }, utils.OrPanic1(NewDataWAL(utils.None[string](), registry.FirstBlock())))) @@ -281,7 +281,7 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { func TestExecution(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -325,7 +325,7 @@ func TestExecution(t *testing.T) { func TestPushBlockAcceptsBlockWithQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -358,7 +358,7 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { func TestGlobalBlockByHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := utils.OrPanic1(NewState(&Config{ Registry: registry, @@ -403,7 +403,7 @@ func TestGlobalBlockByHash(t *testing.T) { func TestReconcileCase1Empty(t *testing.T) { t.Log("Reconcile case 1: Fresh start (empty/empty)") rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() fb := registry.FirstBlock() @@ -423,7 +423,7 @@ func TestReconcileCase1Empty(t *testing.T) { func TestReconcileCase2Corrupted(t *testing.T) { t.Log("Reconcile case 2: QCs lost (corruption), returns error") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() // Persist blocks and QCs normally. @@ -456,7 +456,7 @@ func TestReconcileCase3BlocksLost(t *testing.T) { t.Log("Reconcile case 3: Blocks lost (crash), QCs survive") ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() // First run: populate both WALs. @@ -506,7 +506,7 @@ func TestReconcileCase4Normal(t *testing.T) { t.Log("Reconcile case 4: Normal (a=X, bX)") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) @@ -677,7 +677,7 @@ func TestReconcileCase6QCsAhead(t *testing.T) { t.Log("Reconcile case 6: Prune crash, QCs ahead (a=Y)") rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() // Build 2 sequential QCs. @@ -780,7 +780,7 @@ func TestReconcileCase7BlocksTail(t *testing.T) { t.Log("Reconcile case 7: Persist crash, tail truncation with re-push") ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() // Build 2 sequential QCs. @@ -829,7 +829,7 @@ func TestReconcileCase8BlocksBehind(t *testing.T) { t.Log("Reconcile case 8: QCs ahead normal (b 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 8961595683..25f4475eb0 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -230,7 +230,7 @@ func (env *testEnv) Run(ctx context.Context) error { } func newTestEnv(rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) dataState := utils.OrPanic1(data.NewState( &data.Config{Registry: registry}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), registry.FirstBlock())), diff --git a/sei-tendermint/internal/p2p/giga/avail.go b/sei-tendermint/internal/p2p/giga/avail.go index d45b807f86..cf5c29c5e4 100644 --- a/sei-tendermint/internal/p2p/giga/avail.go +++ b/sei-tendermint/internal/p2p/giga/avail.go @@ -245,7 +245,7 @@ func (x *Service) clientStreamAppQCs(ctx context.Context, c rpc.Client[API]) err if err != nil { return fmt.Errorf("StreamAppQCsRespConv.Decode(): %w", err) } - if err := x.validatorState().Avail().PushAppQC(msg.AppQC, msg.CommitQC); err != nil { + if err := x.validatorState().Avail().PushAppQC(ctx, msg.AppQC, msg.CommitQC); err != nil { return fmt.Errorf("s.PushFirstCommitQC(): %w", err) } } diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 038c0abf07..0d429e4c97 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -17,7 +17,7 @@ import ( func TestAvailClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() env := newTestEnv(registry) var nodes []*testNode diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index a80fa5aa00..6cd33adac2 100644 --- a/sei-tendermint/internal/p2p/giga/consensus_test.go +++ b/sei-tendermint/internal/p2p/giga/consensus_test.go @@ -14,7 +14,7 @@ import ( func TestConsensusClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 7) + registry, keys, _ := epoch.GenRegistry(rng, 7) committee := registry.LatestEpoch().Committee() env := newTestEnv(registry) // Run only a subset of replicas, to enforce timeouts. diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 549a234152..8f98802087 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -92,7 +92,7 @@ func (e *testEnv) Run(ctx context.Context) error { func TestDataClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 2) + registry, keys, _ := epoch.GenRegistry(rng, 2) env := newTestEnv(registry) server := env.AddNode(keys[0]) client := env.AddNode(keys[1]) diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 9be48343c3..3efb3d8930 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -55,6 +55,27 @@ type gigaRouterCommon struct { hashVault hashvault.HashVault } +// newGigaRouterCommon assembles the shared router state both roles embed. The +// role-specific service (full RPC vs block-sync) is passed in; the connection +// pools, app handle, and inbound cap are the same for both. +func newGigaRouterCommon( + cfg *GigaRouterCommonConfig, + key NodeSecretKey, + dataState *data.State, + service *giga.Service, +) *gigaRouterCommon { + return &gigaRouterCommon{ + cfg: cfg, + key: key, + data: dataState, + service: service, + poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), + poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), + app: cfg.App, + inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), + } +} + // buildDataState validates the common config and constructs the data // layer (committee, WAL, State) shared by both giga constructors. // @@ -87,7 +108,18 @@ func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, error) { if err != nil { return nil, fmt.Errorf("data.NewDataWAL(): %w", err) } - dataState, err := data.NewState(&data.Config{Registry: registry}, dataWAL) + // TODO(autobahn): Reading execution tip from app.LastBlockHeight() (Cosmos + // app state DB). Move this into Giga storage; stop depending on the app DB. + var lastExecuted atypes.GlobalBlockNumber + if cfg.App != nil { + if h := cfg.App.LastBlockHeight(); h > 0 { + lastExecuted = atypes.GlobalBlockNumber(h) //nolint:gosec // height is non-negative + } + } + dataState, err := data.NewState(&data.Config{ + Registry: registry, + LastExecutedBlock: lastExecuted, + }, dataWAL) if err != nil { return nil, fmt.Errorf("data.NewState(): %w", err) } @@ -267,6 +299,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 } @@ -519,6 +563,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 afd0e1fa3f..e79ee6a473 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -8,7 +8,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) @@ -24,16 +23,7 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gig } 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 9fe39e6dbf..3176f2aca7 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -2,6 +2,7 @@ package p2p import ( "context" + "errors" "fmt" "net/url" @@ -10,11 +11,25 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) +// 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) (*gigaV if err != nil { return nil, fmt.Errorf("consensus.NewState(): %w", err) } + if err := checkRestartTips(dataState.CommitTipCut(), consensusState.CommitTipCut()); err != nil { + return nil, err + } producerState := producer.NewState(cfg.Producer, consensusState, cfg.App) logger.Info("GigaRouter initialized (validator)", "validators", len(cfg.ValidatorAddrs), "dial_interval", cfg.DialInterval, "inbound_fullnode_cap", cfg.MaxInboundFullnodePeers) return &gigaValidatorRouter{ - 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 } @@ -89,7 +98,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { // EvmProxy on the validator returns None when the sender's shard owner is // us (handle locally via mempool, no HTTP round-trip to self). func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := r.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 1ee7e80f8a..41eba99d14 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -12,6 +12,7 @@ import ( "golang.org/x/time/rate" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/conn" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/proxy" @@ -187,6 +188,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) @@ -275,3 +282,11 @@ func TestGigaRouter_EvmProxy(t *testing.T) { require.Equal(t, expectedRemoteURLs, returnedRemoteURLs) } + +func TestCheckRestartTips(t *testing.T) { + require.NoError(t, checkRestartTips(0, 0)) + require.NoError(t, checkRestartTips(1, 1)) + require.NoError(t, checkRestartTips(1, 2), "consensus lead is fine") + require.ErrorIs(t, checkRestartTips(2, 1), ErrTipBehindData) + require.ErrorIs(t, checkRestartTips(2, 0), ErrTipBehindData) +}