diff --git a/sei-db/ledger_db/block/block_db_test.go b/sei-db/ledger_db/block/block_db_test.go index 7b8bde798b..8a64f1e051 100644 --- a/sei-db/ledger_db/block/block_db_test.go +++ b/sei-db/ledger_db/block/block_db_test.go @@ -1484,8 +1484,6 @@ const ( testSeed = 20260615 ) -var genesisTime = time.Unix(1_700_000_000, 0) - // batch is a contiguous run of blocks at global numbers [first, next) together // with the QC that finalizes them. next == first+len(blocks). type batch struct { @@ -1577,7 +1575,7 @@ func buildFullCommitQC( } else { appQC = utils.None[*types.AppQC]() } - ep := types.NewEpoch(0, types.OpenRoadRange(), genesisTime, committee, 0) + ep := types.NewEpoch(0, types.OpenRoadRange(), committee) cqc := types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) return types.NewFullCommitQC(cqc, headers), blockList } diff --git a/sei-db/ledger_db/block/blocksim/block_generator.go b/sei-db/ledger_db/block/blocksim/block_generator.go index 0b13f9ad0e..bdf15b2230 100644 --- a/sei-db/ledger_db/block/blocksim/block_generator.go +++ b/sei-db/ledger_db/block/blocksim/block_generator.go @@ -168,15 +168,29 @@ 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.NewEpochDuo( + types.NewEpoch(0, types.OpenRoadRange(), committee), + utils.None[*types.Epoch](), + ), + GenesisFirstBlock: 0, + GenesisTimestamp: genesisTime, + } leader := committee.Leader(viewSpec.View()) - appQC := func() utils.Option[*types.AppQC] { - if n := viewSpec.NextGlobalBlock(); n > 0 { - p := types.NewAppProposal(n-1, viewSpec.View().Index, types.AppHash(g.rand.Bytes(hashSizeBytes)), viewSpec.Epoch.EpochIndex()) - return utils.Some(g.fakeAppQC(p)) - } - return utils.None[*types.AppQC]() - }() + // AppQC certifies the prior tipcut (road == prev.Index), not the tipcut + // being built. buildProposal drops AppQCs whose block is not finalized; + // Verify additionally requires road < view. + var appQC utils.Option[*types.AppQC] + if cqc, ok := prev.Get(); ok { + p := types.NewAppProposal( + cqc.GlobalRange().Next-1, + cqc.Proposal().Index(), + types.AppHash(g.rand.Bytes(hashSizeBytes)), + cqc.Proposal().EpochIndex(), + ) + appQC = utils.Some(g.fakeAppQC(p)) + } proposal := utils.OrPanic1(types.NewProposalForTesting( committee, viewSpec, diff --git a/sei-tendermint/AGENTS.md b/sei-tendermint/AGENTS.md index ee38c7c81e..e844aa55a1 100644 --- a/sei-tendermint/AGENTS.md +++ b/sei-tendermint/AGENTS.md @@ -33,6 +33,7 @@ Within sei-tendermint subdirectory optional uint64 index = 1; // required optional AppProposal app = 4; // optional * Avoid removing comments and logs which are not obviously obsolete. Keep the original wording, only fixing mistakes or obsolete parts. +* Prefer concise comments that state invariants, contracts, and observable behavior (plus brief correctness reasoning when non-obvious). Avoid narrating how the implementation works step-by-step. * TestRng instance should be one per test, constructed directly in the test function. In case of nested/table tests, each nested test should create its own instance. Use TestRng.Split() (before spawning) if you need to pass entropy source to a spawned goroutine to ensure deterministic entropy across the goroutines. diff --git a/sei-tendermint/autobahn/types/app_qc.go b/sei-tendermint/autobahn/types/app_qc.go index af7471728f..1642b7adbb 100644 --- a/sei-tendermint/autobahn/types/app_qc.go +++ b/sei-tendermint/autobahn/types/app_qc.go @@ -35,8 +35,17 @@ func (m *AppQC) Next() RoadIndex { return m.Proposal().Next() } -// Verify verifies the AppQC against the committee. -func (m *AppQC) Verify(c *Committee) error { +// Verify checks epoch_index / road against ep, then quorum under ep's committee. +func (m *AppQC) Verify(ep *Epoch) error { + p := m.Proposal() + if got, want := p.EpochIndex(), ep.EpochIndex(); got != want { + return fmt.Errorf("appQC epoch %d, want %d", got, want) + } + if rr := ep.RoadRange(); !rr.Has(p.RoadIndex()) { + return fmt.Errorf("app road_index %v not in epoch %d roads [%v,%v)", + p.RoadIndex(), ep.EpochIndex(), rr.First, rr.Next) + } + c := ep.Committee() return m.vote.verifyQC(c, c.AppQuorum(), m.sigs) } diff --git a/sei-tendermint/autobahn/types/app_qc_test.go b/sei-tendermint/autobahn/types/app_qc_test.go new file mode 100644 index 0000000000..83877c4939 --- /dev/null +++ b/sei-tendermint/autobahn/types/app_qc_test.go @@ -0,0 +1,23 @@ +package types + +import ( + "testing" + + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" +) + +func TestAppQCVerifyChecksEpochAndRoad(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep := NewEpoch(1, RoadRange{First: 100, Next: 200}, committee) + + ok := makeAppQCFor(keys, 0, 150, GenAppHash(rng), 1) + require.NoError(t, ok.Verify(ep)) + + wrongEpoch := makeAppQCFor(keys, 0, 150, GenAppHash(rng), 0) + require.Error(t, wrongEpoch.Verify(ep)) + + wrongRoad := makeAppQCFor(keys, 0, 50, GenAppHash(rng), 1) + require.Error(t, wrongRoad.Verify(ep)) +} diff --git a/sei-tendermint/autobahn/types/block.go b/sei-tendermint/autobahn/types/block.go index d3e283bd23..1cb8732a85 100644 --- a/sei-tendermint/autobahn/types/block.go +++ b/sei-tendermint/autobahn/types/block.go @@ -119,6 +119,10 @@ type GlobalBlock struct { Payload *Payload // Highest known finalized state. FinalAppState utils.Option[*AppProposal] + // Covering CommitQC road (from assembleGlobalBlock). + RoadIndex RoadIndex + // True when GlobalNumber is last in that QC's GlobalRange. + LastInCommitQC bool } // NewBlock creates a new Block. diff --git a/sei-tendermint/autobahn/types/committee_test.go b/sei-tendermint/autobahn/types/committee_test.go index 996e2a812a..ba14b06178 100644 --- a/sei-tendermint/autobahn/types/committee_test.go +++ b/sei-tendermint/autobahn/types/committee_test.go @@ -129,10 +129,10 @@ func TestPrepareQCVerifyChecksEpochBinding(t *testing.T) { require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First})).Verify(ep)) - wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) + wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), 1) require.Error(t, sign(wrongEpoch).Verify(ep)) - outOfRoads := newProposal(View{Index: ep.RoadRange().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](), 1) require.Error(t, sign(outOfRoads).Verify(ep)) } @@ -145,10 +145,10 @@ func TestCommitQCVerifyChecksEpochBinding(t *testing.T) { require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First})).Verify(ep)) - wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), ep.FirstBlock()) + wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, utils.None[*AppProposal](), 1) require.Error(t, sign(wrongEpoch).Verify(ep)) - outOfRoads := newProposal(View{Index: ep.RoadRange().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](), 1) require.Error(t, sign(outOfRoads).Verify(ep)) } @@ -171,18 +171,18 @@ func TestCommitQCVerifyChecksWeight(t *testing.T) { func TestAppQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() ep, keys := makeEpoch(rng) - vote := NewAppVote(NewAppProposal(0, 0, GenAppHash(rng), ep.EpochIndex())) + vote := NewAppVote(NewAppProposal(0, ep.RoadRange().First, GenAppHash(rng), ep.EpochIndex())) heavyOnly := NewAppQC([]*Signed[*AppVote]{ Sign(keys[0], vote), }) - require.NoError(t, heavyOnly.Verify(ep.Committee())) + require.NoError(t, heavyOnly.Verify(ep)) lightMajority := NewAppQC([]*Signed[*AppVote]{ Sign(keys[1], vote), Sign(keys[2], vote), }) - require.Error(t, lightMajority.Verify(ep.Committee())) + require.Error(t, lightMajority.Verify(ep)) } func TestTimeoutQCVerifyChecksEpochBinding(t *testing.T) { diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index 3ab13df54a..2b4263571b 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -1,51 +1,48 @@ package types import ( - "time" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) // 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. -// Use in tests and genesis epochs where no upper bound is known yet. -func OpenRoadRange() RoadRange { return RoadRange{First: 0, Last: utils.Max[RoadIndex]()} } +// OpenRoadRange returns [0, Max) for tests/genesis when no upper bound is known yet. +func OpenRoadRange() RoadRange { return RoadRange{First: 0, Next: utils.Max[RoadIndex]()} } + +func (r RoadRange) Has(idx RoadIndex) bool { return idx >= r.First && idx < r.Next } -// 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 } +// IsLastRoad is true when idx+1 == Next (same shape as GlobalRange.IsLastBlock). +func (r RoadRange) IsLastRoad(idx RoadIndex) bool { return idx+1 == r.Next } // Epoch holds the complete context for a single epoch. // Retrieved from the local Registry; never transmitted on the wire. +// +// First global block / timestamp floors are not on Epoch: they come from the +// last CommitQC of the prior epoch (or genesis GenDoc via Registry), and the +// registry does not store CommitQCs. type Epoch struct { utils.ReadOnly - epochIndex EpochIndex - roads RoadRange - firstTimestamp time.Time - committee *Committee - firstBlock GlobalBlockNumber + epochIndex EpochIndex + roads RoadRange + committee *Committee } -// NewEpoch constructs an Epoch. -func NewEpoch(index EpochIndex, roads RoadRange, firstTimestamp time.Time, committee *Committee, firstBlock GlobalBlockNumber) *Epoch { +func NewEpoch(index EpochIndex, roads RoadRange, committee *Committee) *Epoch { return &Epoch{ - epochIndex: index, - roads: roads, - firstTimestamp: firstTimestamp, - committee: committee, - firstBlock: firstBlock, + epochIndex: index, + roads: roads, + committee: committee, } } -func (e *Epoch) EpochIndex() EpochIndex { return e.epochIndex } -func (e *Epoch) RoadRange() RoadRange { return e.roads } -func (e *Epoch) FirstTimestamp() time.Time { return e.firstTimestamp } -func (e *Epoch) Committee() *Committee { return e.committee } -func (e *Epoch) FirstBlock() GlobalBlockNumber { return e.firstBlock } +func (e *Epoch) EpochIndex() EpochIndex { return e.epochIndex } +func (e *Epoch) RoadRange() RoadRange { return e.roads } +func (e *Epoch) Committee() *Committee { return e.committee } diff --git a/sei-tendermint/autobahn/types/epoch_duo.go b/sei-tendermint/autobahn/types/epoch_duo.go new file mode 100644 index 0000000000..3a6dc3ecf6 --- /dev/null +++ b/sei-tendermint/autobahn/types/epoch_duo.go @@ -0,0 +1,69 @@ +package types + +import ( + "errors" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// EpochDuo is a Prev|Current epoch pair. It is a value for verifying a tipcut +// that may carry an AppQC from the previous epoch — not a shared cross-layer +// state machine. Live avail tip state is Current + App prune anchor instead. +// +// Current is always set; Prev is absent iff Current is epoch 0, otherwise +// contiguous with Current (index Current-1 and Prev.Next == Current.First). +type EpochDuo struct { + Prev utils.Option[*Epoch] + Current *Epoch +} + +// NewEpochDuo builds a Prev|Current pair. Panics unless +// prev≠None ⇔ current.EpochIndex()>0, and when Prev is present it must be +// contiguous with Current. +func NewEpochDuo(current *Epoch, prev utils.Option[*Epoch]) EpochDuo { + cur := current.EpochIndex() + p, hasPrev := prev.Get() + if hasPrev != (cur > 0) { + panic(fmt.Sprintf("NewEpochDuo: Prev present=%v but Current epoch %d (want Prev iff Current>0)", + hasPrev, cur)) + } + if hasPrev { + if p.EpochIndex()+1 != cur { + panic(fmt.Sprintf("NewEpochDuo: Prev epoch %d not contiguous with Current %d", + p.EpochIndex(), cur)) + } + if got, want := p.RoadRange().Next, current.RoadRange().First; got != want { + panic(fmt.Sprintf("NewEpochDuo: Prev roads end at %d, Current starts at %d", got, want)) + } + } + return EpochDuo{Prev: prev, Current: current} +} + +// ByRoad returns the epoch containing roadIdx within this pair. +// Future roads and roads before Prev return an error (ErrPruned when behind). +func (w EpochDuo) ByRoad(roadIdx RoadIndex) (*Epoch, error) { + if roadIdx >= w.Current.RoadRange().Next { + return nil, errors.New("road belongs to future epoch") + } + if w.Current.RoadRange().Has(roadIdx) { + return w.Current, nil + } + if prev, ok := w.Prev.Get(); ok && prev.RoadRange().Has(roadIdx) { + return prev, nil + } + return nil, ErrPruned +} + +func (w EpochDuo) String() string { + s := "epochs [" + sep := "" + // Prev then Current so a window {4,5} prints as "epochs [4, 5]". + for _, oep := range [2]utils.Option[*Epoch]{w.Prev, utils.Some(w.Current)} { + if ep, ok := oep.Get(); ok { + s += fmt.Sprintf("%s%d", sep, ep.EpochIndex()) + sep = ", " + } + } + 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..ae6fdf964c --- /dev/null +++ b/sei-tendermint/autobahn/types/epoch_duo_test.go @@ -0,0 +1,125 @@ +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, rng utils.Rng) (prev, current *types.Epoch) { + t.Helper() + 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}, committee) + current = types.NewEpoch(1, types.RoadRange{First: 100, Next: 200}, committee) + return prev, current +} + +func TestNewEpochDuo_PanicsOnNonContiguousIndex(t *testing.T) { + rng := utils.TestRng() + prev, _ := testDuoEpochs(t, rng) + weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} + committee := utils.OrPanic1(types.NewCommittee(weights)) + // Roads abut, but index jumps 0 → 2. + current := types.NewEpoch(2, types.RoadRange{First: 100, Next: 200}, committee) + defer func() { + if recover() == nil { + t.Fatal("NewEpochDuo with non-contiguous indices should panic") + } + }() + _ = types.NewEpochDuo(current, utils.Some(prev)) +} + +func TestNewEpochDuo_PanicsOnNonContiguousRoads(t *testing.T) { + rng := utils.TestRng() + weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} + committee := utils.OrPanic1(types.NewCommittee(weights)) + prev := types.NewEpoch(0, types.OpenRoadRange(), committee) + current := types.NewEpoch(1, types.RoadRange{First: 100, Next: 200}, committee) + defer func() { + if recover() == nil { + t.Fatal("NewEpochDuo with non-abutting roads should panic") + } + }() + _ = types.NewEpochDuo(current, utils.Some(prev)) +} + +func TestNewEpochDuo_PanicsOnPrevCurrentMismatch(t *testing.T) { + rng := utils.TestRng() + prev, current := testDuoEpochs(t, rng) + t.Run("prev_absent_with_current_gt_0", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + _ = types.NewEpochDuo(current, utils.None[*types.Epoch]()) + }) + t.Run("prev_present_with_epoch_0", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + _ = types.NewEpochDuo(prev, utils.Some(prev)) + }) +} + +func TestByRoad(t *testing.T) { + rng := utils.TestRng() + prev, current := testDuoEpochs(t, rng) + weights := map[types.PublicKey]uint64{types.GenSecretKey(rng).Public(): 1} + committee := utils.OrPanic1(types.NewCommittee(weights)) + // Prev absent only for epoch 0. + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 100}, committee) + // Contiguous duo starting past road 0 so a behind-window road is possible. + latePrev := types.NewEpoch(0, types.RoadRange{First: 100, Next: 200}, committee) + lateCurrent := types.NewEpoch(1, types.RoadRange{First: 200, Next: 300}, committee) + + currentOnly := types.NewEpochDuo(ep0, utils.None[*types.Epoch]()) + withPrev := types.NewEpochDuo(current, utils.Some(prev)) + lateDuo := types.NewEpochDuo(lateCurrent, utils.Some(latePrev)) + + for _, tc := range []struct { + name string + w types.EpochDuo + road types.RoadIndex + want *types.Epoch + wantErr error // nil = success; errors.Is match when non-nil + anyErr bool // future roads: non-nil err, not a typed sentinel + }{ + {"ep0_hit", currentOnly, 50, ep0, nil, false}, + {"ep0_after", currentOnly, 100, nil, nil, true}, + {"prev_hit", withPrev, 50, prev, nil, false}, + {"duo_current_hit", withPrev, 150, current, nil, false}, + {"duo_after", withPrev, 200, nil, nil, true}, + {"behind", lateDuo, 50, nil, types.ErrPruned, false}, + } { + t.Run(tc.name, func(t *testing.T) { + ep, err := tc.w.ByRoad(tc.road) + if tc.anyErr { + if err == nil { + t.Fatalf("ByRoad(%d) = nil err, want non-nil", tc.road) + } + return + } + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("ByRoad(%d) = %v, want %v", tc.road, err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("ByRoad(%d): %v", tc.road, err) + } + if ep != tc.want { + t.Fatalf("ByRoad(%d) = %v, want %v", tc.road, ep, tc.want) + } + }) + } +} diff --git a/sei-tendermint/autobahn/types/epoch_test.go b/sei-tendermint/autobahn/types/epoch_test.go new file mode 100644 index 0000000000..528aff6cbc --- /dev/null +++ b/sei-tendermint/autobahn/types/epoch_test.go @@ -0,0 +1,23 @@ +package types + +import ( + "testing" + + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" +) + +func TestRoadRange_Has(t *testing.T) { + r := RoadRange{First: 10, Next: 13} + require.False(t, r.Has(9)) + require.True(t, r.Has(10)) + require.True(t, r.Has(12)) + require.False(t, r.Has(13)) +} + +func TestRoadRange_IsLastRoad(t *testing.T) { + r := RoadRange{First: 10, Next: 13} // covers 10, 11, 12 + require.False(t, r.IsLastRoad(10)) + require.False(t, r.IsLastRoad(11)) + require.True(t, r.IsLastRoad(12)) + require.False(t, r.IsLastRoad(13)) +} diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index bb78f668c1..a45e14bc84 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -77,6 +77,10 @@ func (g GlobalRange) Has(n GlobalBlockNumber) bool { return g.First <= n && n < g.Next } +func (g GlobalRange) IsLastBlock(n GlobalBlockNumber) bool { + return n+1 == g.Next +} + // RoadIndex is the index of the consensus instance. type RoadIndex uint64 @@ -110,7 +114,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 } @@ -121,43 +125,49 @@ func (v View) Next() View { return v } -// 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. +// ViewSpec is the local context for starting a view: justification QCs plus a +// Prev|Current EpochDuo. Attached AppQC may be Current or Current-1 (Prev lag). type ViewSpec struct { // WARNING: currently we have implicit assumption that // TimeoutQC.View().Index == CommitQC.Index.Next(), // I.e. that TimeoutQC comes from the expected consensus instance. CommitQC utils.Option[*CommitQC] TimeoutQC utils.Option[*TimeoutQC] - Epoch *Epoch + Epochs EpochDuo + // Genesis floors used only when CommitQC is None (chain start). + // Copied from Registry; ignored when CommitQC is present. + GenesisFirstBlock GlobalBlockNumber + GenesisTimestamp time.Time } +// Epoch is the proposing/voting epoch (Epochs.Current). +func (vs *ViewSpec) Epoch() *Epoch { return vs.Epochs.Current } + // NextGlobalBlock returns the first global block number expected in the next proposal. -// CommitQC is None only at global block 0 (genesis), in which case it returns Epoch[0].FirstBlock. +// CommitQC is None only at chain start, in which case it returns GenesisFirstBlock. // For all other views, including the first view of a non-genesis epoch, CommitQC is present and it returns CommitQC.GlobalRange().Next. func (vs *ViewSpec) NextGlobalBlock() GlobalBlockNumber { if cQC, ok := vs.CommitQC.Get(); ok { return cQC.GlobalRange().Next } - return vs.Epoch.FirstBlock() + return vs.GenesisFirstBlock } // View is the view justified by vs. 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.GenesisTimestamp } // Proposal is the road tipcut proposal. @@ -205,6 +215,13 @@ func (m *Proposal) App() utils.Option[*AppProposal] { return m.app } // EpochIndex returns the epoch index encoded in the proposal. func (m *Proposal) EpochIndex() EpochIndex { return m.view.EpochIndex } +// acceptsAppEpoch reports whether appEp is usable by this proposal: the +// proposal's own epoch, or the immediately preceding epoch. +func (m *Proposal) acceptsAppEpoch(appEp EpochIndex) bool { + cur := m.EpochIndex() + return appEp == cur || cur > 0 && appEp == cur-1 +} + // GlobalRange returns the proposed global block range. func (m *Proposal) GlobalRange() GlobalRange { return m.globalRange @@ -299,7 +316,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()) } @@ -345,21 +362,28 @@ func buildProposal( } } app := ProposalOpt(appQC) - // If the new appProposal is not later than the previous one, then clear appQC. + // Attach AppQC only when newer than the previous tipcut's App; otherwise + // carry the CommitQC App forward with no attachment. if old := AppOpt(ProposalOpt(viewSpec.CommitQC)); NextOpt(app) <= NextOpt(old) { app = old appQC = utils.None[*AppQC]() } - // If the new appProposal is from the future (which may happen if this node is behind), then clear appQC. - // The proposal will be useless in this case, but at least it will be valid. + // Ahead of this tipcut: do not attach; keep prior CommitQC App (may be None). if a, ok := app.Get(); ok && a.GlobalNumber() >= viewSpec.NextGlobalBlock() { - app = utils.None[*AppProposal]() + app = AppOpt(ProposalOpt(viewSpec.CommitQC)) appQC = utils.None[*AppQC]() } // Normalize the creation timestamp. if wantMin := viewSpec.NextTimestamp(); timestamp.Before(wantMin) { timestamp = wantMin } + if a, ok := app.Get(); ok { + cur := viewSpec.Epoch().EpochIndex() + if a.EpochIndex() != cur && (cur == 0 || a.EpochIndex() != cur-1) { + app = utils.None[*AppProposal]() + appQC = utils.None[*AppQC]() + } + } proposal := newProposal(viewSpec.View(), timestamp, laneRanges, app, viewSpec.NextGlobalBlock()) if proposal.GlobalRange().Len() == 0 { return nil, appQC, errors.New("empty tipcut: need at least one LaneQC") @@ -411,8 +435,9 @@ func (m *FullProposal) TimeoutQC() utils.Option[*TimeoutQC] { } // Verify verifies the FullProposal against the current view. +// Attached AppQC may be Current or Current-1 (Prev lag for unfinished AppQC). func (m *FullProposal) Verify(vs ViewSpec) error { - c := vs.Epoch.Committee() + c := vs.Epoch().Committee() return scope.Parallel(func(s scope.ParallelScope) error { // Does the view match? if got, want := m.proposal.Msg().View(), vs.View(); got != want { @@ -440,7 +465,7 @@ func (m *FullProposal) Verify(vs ViewSpec) error { // Verify timeoutQC. if tQC, ok := m.timeoutQC.Get(); ok { s.Spawn(func() error { - if err := tQC.Verify(vs.Epoch, vs.CommitQC); err != nil { + if err := tQC.Verify(vs.Epoch(), vs.CommitQC); err != nil { return fmt.Errorf("timeoutQC: %w", err) } return nil @@ -459,7 +484,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. @@ -489,36 +514,60 @@ func (m *FullProposal) Verify(vs ViewSpec) error { }) } } + proposalApp := proposal.App() + app, appPresent := proposalApp.Get() + if !appPresent { + return nil + } + if !proposal.acceptsAppEpoch(app.EpochIndex()) { + return fmt.Errorf( + "app epoch_index %d incompatible with proposal epoch %d", + app.EpochIndex(), proposal.EpochIndex(), + ) + } + // Verify the appQC. - if got, wantMin := NextOpt(m.proposal.Msg().App()), NextOpt(AppOpt(ProposalOpt(vs.CommitQC))); got < wantMin { - return errors.New("AppProposal lower than in previous CommitQC") - } else if got == wantMin { - if m.appQC.IsPresent() { - return errors.New("unnecessary appQC") - } - } 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) + previousApp := AppOpt(ProposalOpt(vs.CommitQC)) + previous, previousPresent := previousApp.Get() + if previousPresent { + if app.RoadIndex() < previous.RoadIndex() { + return errors.New("AppProposal lower than in previous CommitQC") } - appQC, ok := m.appQC.Get() - if !ok { - return errors.New("appQC missing") - } - if appQC.vote.hash != NewHashed(NewAppVote(app)).hash { - return errors.New("appQC doesn't match the proposal") - } - s.Spawn(func() error { - if err := appQC.Verify(c); err != nil { - return fmt.Errorf("appQC: %w", err) + if app.RoadIndex() == previous.RoadIndex() { + if m.appQC.IsPresent() { + return errors.New("unnecessary appQC") + } + if NewHashed(NewAppVote(app)).hash != NewHashed(NewAppVote(previous)).hash { + return errors.New("AppProposal differs from previous CommitQC") } return nil - }) - if got, want := appQC.Proposal().GlobalNumber(), vs.NextGlobalBlock(); got >= want { - return fmt.Errorf("appQC for block %v, while only %v blocks were finalized", got, want) } } + appEpoch := app.EpochIndex() + cur := vs.Epoch() + appQC, ok := m.appQC.Get() + if !ok { + return errors.New("appQC missing") + } + if appQC.vote.hash != NewHashed(NewAppVote(app)).hash { + return errors.New("appQC doesn't match the proposal") + } + s.Spawn(func() error { + ep := vs.Epochs.Current + if appEpoch != cur.EpochIndex() { + ep = vs.Epochs.Prev.OrPanic("previous epoch required for previous-epoch AppQC") + } + if err := appQC.Verify(ep); err != nil { + return fmt.Errorf("appQC: %w", err) + } + return nil + }) + if got, want := appQC.Proposal().RoadIndex(), vs.View().Index; got >= want { + return fmt.Errorf("appQC road %v ahead of tipcut view %v", got, want) + } + if got, want := appQC.Proposal().GlobalNumber(), vs.NextGlobalBlock(); got >= want { + return fmt.Errorf("appQC for block %v, while only %v blocks were finalized", got, want) + } return nil }) } diff --git a/sei-tendermint/autobahn/types/proposal_test.go b/sei-tendermint/autobahn/types/proposal_test.go index 0228ccc1c3..e269bd843b 100644 --- a/sei-tendermint/autobahn/types/proposal_test.go +++ b/sei-tendermint/autobahn/types/proposal_test.go @@ -8,17 +8,39 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) -// genFreshEpoch returns an epoch whose road range is OpenRoadRange (so road index 0 -// is always valid for a ViewSpec with no CommitQC) but whose epoch index and first -// block are randomised to prevent tests from silently passing on zero-value defaults. -func genFreshEpoch(rng utils.Rng, committee *Committee) *Epoch { - return NewEpoch( - GenEpochIndex(rng), - OpenRoadRange(), - time.Time{}, - committee, - GlobalBlockNumber(rng.Uint64()%1000000)+1, - ) +// genFreshEpoch returns epoch 0 with OpenRoadRange (road 0 valid for a ViewSpec +// with no CommitQC). +func genFreshEpoch(_ utils.Rng, committee *Committee) *Epoch { + return NewEpoch(0, OpenRoadRange(), committee) +} + +// genesisViewSpec builds a CommitQC-absent ViewSpec with randomised genesis +// floors so NextGlobalBlock/NextTimestamp tests do not pass on zeros. +func genesisViewSpec(rng utils.Rng, ep *Epoch) ViewSpec { + return ViewSpec{ + Epochs: EpochDuoForTest(ep), + GenesisFirstBlock: GlobalBlockNumber(rng.Uint64()%1000000) + 1, + GenesisTimestamp: utils.GenTimestamp(rng), + } +} + +// withTimeoutQC returns base with TimeoutQC set, preserving genesis floors +// (needed when CommitQC is still None, e.g. reproposal at view number > 0). +func withTimeoutQC(base ViewSpec, tqc *TimeoutQC) ViewSpec { + base.TimeoutQC = utils.Some(tqc) + return base +} + +// viewSpecContiguousDuo builds Prev|Current with abutting roads [0,1)|[1,Max) and +// CommitQCs advanced into Current so View.Index == 2. That leaves room for a +// Current-epoch AppQC at road 1 (strictly before the tipcut view). +func viewSpecContiguousDuo(keys []SecretKey, committee *Committee) ViewSpec { + const split RoadIndex = 1 + prev := NewEpoch(0, RoadRange{First: 0, Next: split}, committee) + current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, committee) + qc0 := BuildCommitQC(prev, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + qc1 := BuildCommitQC(current, keys, utils.Some(qc0), nil, utils.None[*AppQC]()) + return ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(current, utils.Some(prev))} } // leaderKey returns the secret key for the leader of the given view. @@ -71,11 +93,19 @@ func makeAppQCFor(keys []SecretKey, globalNum GlobalBlockNumber, roadIdx RoadInd return NewAppQC(votes) } +func TestGlobalRange_IsLastBlock(t *testing.T) { + g := GlobalRange{First: 10, Next: 13} // covers 10, 11, 12 + require.False(t, g.IsLastBlock(10)) + require.False(t, g.IsLastBlock(11)) + require.True(t, g.IsLastBlock(12)) + require.False(t, g.IsLastBlock(13)) +} + func TestProposalVerifyRejectsEmptyTipcut(t *testing.T) { rng := utils.TestRng() committee, _ := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) // Direct Proposal.Verify rejects empty tipcuts (no LaneQCs / zero GlobalRange). // Local propose waits via WaitForLaneQCs; verification must refuse empty tipcuts @@ -97,7 +127,7 @@ func TestProposalVerifyFreshWithBlocks(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) // Produce a LaneQC for the proposer's lane. @@ -113,7 +143,7 @@ func TestNewProposalRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testing. rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -132,8 +162,8 @@ func TestProposalBlockTimestampStrictlyMonotone(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - firstBlock := ep.FirstBlock() - vs0 := ViewSpec{Epoch: ep} + vs0 := genesisViewSpec(rng, ep) + firstBlock := vs0.GenesisFirstBlock proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() @@ -156,7 +186,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: EpochDuoForTest(ep)} proposer1 := leaderKey(committee, keys, vs1.View()) secondProposal := utils.OrPanic1(NewProposal( @@ -180,14 +210,18 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) genesisTimestamp := time.Now() - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), genesisTimestamp, committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) - vs := ViewSpec{Epoch: ep} + ep := NewEpoch(0, OpenRoadRange(), committee) + vs := ViewSpec{ + Epochs: EpochDuoForTest(ep), + GenesisFirstBlock: GlobalBlockNumber(rng.Uint64()%1000000) + 1, + GenesisTimestamp: genesisTimestamp, + } k := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(k, vs, genesisTimestamp, oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) require.NoError(t, fp.Verify(vs)) vsLater := vs - vsLater.Epoch = NewEpoch(ep.EpochIndex(), ep.RoadRange(), fp.Proposal().Msg().Timestamp().Add(time.Nanosecond), committee, ep.FirstBlock()) + vsLater.GenesisTimestamp = genesisTimestamp.Add(time.Second) require.Error(t, fp.Verify(vsLater)) }) @@ -195,7 +229,7 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs0 := ViewSpec{Epoch: ep} + vs0 := genesisViewSpec(rng, ep) proposer0 := leaderKey(committee, keys, vs0.View()) lane := proposer0.Public() lQC := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -213,8 +247,8 @@ func TestProposalVerifyRejectsNonMonotoneTimestamp(t *testing.T) { utils.None[*AppQC](), )) - vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epoch: ep} - vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epoch: ep} + vs1a := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0a)), Epochs: EpochDuoForTest(ep)} + vs1b := ViewSpec{CommitQC: utils.Some(makeCommitQCFromProposal(keys, fp0b)), Epochs: EpochDuoForTest(ep)} proposer1 := leaderKey(committee, keys, vs1a.View()) fp1a := utils.OrPanic1(NewProposal( @@ -235,13 +269,13 @@ func TestProposalVerifyRejectsViewMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a valid proposal at genesis view (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := genesisViewSpec(rng, ep) leader0 := leaderKey(committee, keys, vs0.View()) fp := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) // 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: EpochDuoForTest(ep)} err := fp.Verify(vs1) require.Error(t, err) } @@ -250,7 +284,7 @@ func TestProposalVerifyRejectsForgedSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) // Build two valid proposals with different timestamps. @@ -267,7 +301,7 @@ func TestProposalVerifyRejectsWrongProposer(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) correctLeader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(correctLeader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -294,7 +328,7 @@ func TestProposalVerifyRejectsInconsistentTimeoutQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} // no timeoutQC + vs := genesisViewSpec(rng, ep) // no timeoutQC proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -320,7 +354,7 @@ func TestProposalVerifyRejectsNonCommitteeLane(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -352,7 +386,7 @@ func TestProposalVerifyAcceptsImplicitLaneRange(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -384,7 +418,7 @@ func TestProposalVerifyAcceptsNonContiguousImplicitRanges(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -416,7 +450,7 @@ func TestProposalVerifyRejectsLaneRangeFirstMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(proposerKey, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -454,7 +488,7 @@ func TestProposalVerifyRejectsMissingLaneQC(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -476,7 +510,7 @@ func TestProposalVerifyRejectsLaneQCBlockNumberMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -500,7 +534,7 @@ func TestProposalVerifyRejectsInvalidLaneQCSignature(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := keys[0].Public() @@ -548,7 +582,7 @@ func TestProposalVerifyRejectsLaneRangeLongerThanMaxLaneRangeInProposal(t *testi time.Now(), []*LaneRange{NewLaneRange(lane, 0, utils.Some(NewBlock(lane, MaxLaneRangeInProposal, GenBlockHeaderHash(rng), GenPayload(rng)).Header()))}, utils.None[*AppProposal](), - ep.FirstBlock(), + 1, ) require.Error(t, oversized.Verify(ep)) } @@ -561,7 +595,7 @@ func makeFullProposal( appQC utils.Option[*AppQC], ) *FullProposal { committee := ep.Committee() - vs := ViewSpec{CommitQC: prev, Epoch: ep} + vs := ViewSpec{CommitQC: prev, Epochs: EpochDuoForTest(ep)} return utils.OrPanic1(NewProposal( leaderKey(committee, keys, vs.View()), vs, time.Now(), @@ -579,41 +613,78 @@ func makeCommitQC(keys []SecretKey, fullProposal *FullProposal) *CommitQC { return NewCommitQC(votes) } -func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { +func TestProposalVerifyAllowsAbsentAppProposal(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - // Construct commitQC for index 1 with AppProposal - // and Proposal for index 2 without any app proposal. - // Such a proposal should fail validation, because app proposals need to be monotone. l := keys[0].Public() lQCs := map[LaneID]*LaneQC{l: makeLaneQC(rng, committee, keys, l, 0, GenBlockHeaderHash(rng))} commitQC0 := makeCommitQC(keys, makeFullProposal(ep, keys, utils.None[*CommitQC](), lQCs, utils.None[*AppQC]())) appQC0 := makeAppQCFor(keys, commitQC0.GlobalRange().First, 0, GenAppHash(rng), ep.EpochIndex()) - vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epoch: ep} + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: EpochDuoForTest(ep)} commitQC1a := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), oneLaneQCMap(rng, committee, keys, vs1), utils.Some(appQC0))) commitQC1b := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), oneLaneQCMap(rng, committee, keys, vs1), utils.None[*AppQC]())) - vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epoch: ep} + vs := ViewSpec{CommitQC: utils.Some(commitQC1a), Epochs: EpochDuoForTest(ep)} fp2a := makeFullProposal(ep, keys, utils.Some(commitQC1a), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]()) fp2b := makeFullProposal(ep, keys, utils.Some(commitQC1b), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]()) - // We construct the invalid proposal by constructing 2 alternative futures: one with appQC, one without. require.NoError(t, fp2a.Verify(vs)) - require.Error(t, fp2b.Verify(vs)) + require.NoError(t, fp2b.Verify(vs)) +} + +func TestProposalVerifyRejectsAppProposalLowerThanPrevious(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep := genFreshEpoch(rng, committee) + + commitQC0 := BuildCommitQC(ep, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + commitQC1 := BuildCommitQC(ep, keys, utils.Some(commitQC0), nil, utils.None[*AppQC]()) + appQC1 := makeAppQCFor( + keys, + commitQC1.GlobalRange().First, + commitQC1.Proposal().Index(), + GenAppHash(rng), + ep.EpochIndex(), + ) + commitQC2 := BuildCommitQC(ep, keys, utils.Some(commitQC1), nil, utils.Some(appQC1)) + vs := ViewSpec{CommitQC: utils.Some(commitQC2), Epochs: EpochDuoForTest(ep)} + leader := leaderKey(committee, keys, vs.View()) + fp := utils.OrPanic1(NewProposal( + leader, + vs, + time.Now(), + oneLaneQCMap(rng, committee, keys, vs), + utils.None[*AppQC](), + )) + + carried := fp.Proposal().Msg().App().OrPanic("fixture carries an AppProposal") + proposalPB := ProposalConv.Encode(fp.Proposal().Msg()) + proposalPB.App = AppProposalConv.Encode(NewAppProposal( + carried.GlobalNumber(), + carried.RoadIndex()-1, + carried.AppHash(), + carried.EpochIndex(), + )) + lowerProposal := utils.OrPanic1(ProposalConv.Decode(proposalPB)) + fullPB := FullProposalConv.Encode(fp) + fullPB.ProposalV2 = SignedProposalConv.Encode(Sign(leader, lowerProposal)) + lower := utils.OrPanic1(FullProposalConv.Decode(fullPB)) + + require.Error(t, lower.Verify(vs)) } -func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { +func TestProposalVerifyIgnoresAppQCWithoutAppProposal(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 := genesisViewSpec(rng, ep) // no previous commitQC, so app starts at None leader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) // Attach an unrequested AppQC. - appQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) + appQC := makeAppQCFor(keys, vs.GenesisFirstBlock, 0, GenAppHash(rng), ep.EpochIndex()) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: fp.laneQCs, @@ -621,59 +692,56 @@ func TestProposalVerifyRejectsUnnecessaryAppQC(t *testing.T) { timeoutQC: fp.timeoutQC, } err := tamperedFP.Verify(vs) - require.Error(t, err) + require.NoError(t, err) } func TestProposalVerifyRejectsMissingAppQC(t *testing.T) { 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 + vs := viewSpecContiguousDuo(keys, committee) 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()) + goodAppQC := makeAppQCFor(keys, 0, 1, GenAppHash(rng), 1) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: fp.laneQCs, } - err := tamperedFP.Verify(vs) - require.Error(t, err) + require.Error(t, tamperedFP.Verify(vs)) } func TestProposalVerifyRejectsAppQCMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := viewSpecContiguousDuo(keys, committee) 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()) + goodAppQC := makeAppQCFor(keys, 0, 1, GenAppHash(rng), 1) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) - differentAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, GenAppHash(rng), ep.EpochIndex()) + differentAppQC := makeAppQCFor(keys, 0, 1, GenAppHash(rng), 1) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: fp.laneQCs, appQC: utils.Some(differentAppQC), } - err := tamperedFP.Verify(vs) - require.Error(t, err) + require.Error(t, tamperedFP.Verify(vs)) } 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)} + vs := viewSpecContiguousDuo(keys, committee) leader := leaderKey(committee, keys, vs.View()) makeAppQCWithEpoch := func(epochIdx EpochIndex) *AppQC { - p := NewAppProposal(0, 0, GenAppHash(rng), epochIdx) + road := RoadIndex(0) + if epochIdx >= 1 { + road = 1 + } + p := NewAppProposal(0, road, GenAppHash(rng), epochIdx) v := NewAppVote(p) var votes []*Signed[*AppVote] for _, k := range keys { @@ -682,46 +750,223 @@ func TestProposalVerifyRejectsAppProposalWrongEpoch(t *testing.T) { return NewAppQC(votes) } - // app epoch matches proposal epoch — accepted. - fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(0)))) + // AppQC from Current — accepted. + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(1)))) require.NoError(t, fp.Verify(vs)) - // app epoch differs from proposal epoch — rejected. - fpWrong := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(1)))) - require.Error(t, fpWrong.Verify(vs)) + // AppQC from Prev (Current-1 lag) — accepted. + fpPrev := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(makeAppQCWithEpoch(0)))) + require.NoError(t, fpPrev.Verify(vs)) + appPrev, ok := fpPrev.Proposal().Msg().App().Get() + require.True(t, ok) + require.Equal(t, EpochIndex(0), appPrev.EpochIndex()) + + // AppQC outside {Current, Current-1} — omitted during construction. + outside := makeAppQCWithEpoch(2) + fpOutside := utils.OrPanic1(NewProposal( + leader, + vs, + time.Now(), + oneLaneQCMap(rng, committee, keys, vs), + utils.Some(outside), + )) + require.False(t, fpOutside.Proposal().Msg().App().IsPresent()) + require.False(t, fpOutside.appQC.IsPresent()) + require.NoError(t, fpOutside.Verify(vs)) + + // A leader that puts the incompatible App back on the wire is rejected. + proposalPB := ProposalConv.Encode(fpOutside.Proposal().Msg()) + proposalPB.App = AppProposalConv.Encode(outside.Proposal()) + wrongEpochProposal := utils.OrPanic1(ProposalConv.Decode(proposalPB)) + fullPB := FullProposalConv.Encode(fpOutside) + fullPB.ProposalV2 = SignedProposalConv.Encode(Sign(leader, wrongEpochProposal)) + wrongEpoch := utils.OrPanic1(FullProposalConv.Decode(fullPB)) + require.Error(t, wrongEpoch.Verify(vs)) } -func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { +func TestProposalVerifyRejectsChangedCarriedAppProposal(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep := genFreshEpoch(rng, committee) + lane := keys[0].Public() + + laneQCs0 := map[LaneID]*LaneQC{lane: makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng))} + commitQC0 := makeCommitQC(keys, makeFullProposal(ep, keys, utils.None[*CommitQC](), laneQCs0, utils.None[*AppQC]())) + appQC0 := makeAppQCFor(keys, commitQC0.GlobalRange().First, commitQC0.Proposal().Index(), GenAppHash(rng), ep.EpochIndex()) + vs1 := ViewSpec{CommitQC: utils.Some(commitQC0), Epochs: EpochDuoForTest(ep)} + commitQC1 := makeCommitQC(keys, makeFullProposal(ep, keys, utils.Some(commitQC0), oneLaneQCMap(rng, committee, keys, vs1), utils.Some(appQC0))) + vs := ViewSpec{CommitQC: utils.Some(commitQC1), Epochs: EpochDuoForTest(ep)} + leader := leaderKey(committee, keys, vs.View()) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) + + msg := fp.Proposal().Msg() + carried := msg.App().OrPanic("fixture CommitQC carries an AppProposal") + changed := NewAppProposal( + carried.GlobalNumber(), + carried.RoadIndex(), + GenAppHash(rng), + carried.EpochIndex(), + ) + proposalPB := ProposalConv.Encode(msg) + proposalPB.App = AppProposalConv.Encode(changed) + changedProposal := utils.OrPanic1(ProposalConv.Decode(proposalPB)) + fullPB := FullProposalConv.Encode(fp) + fullPB.ProposalV2 = SignedProposalConv.Encode(Sign(leader, changedProposal)) + tampered := utils.OrPanic1(FullProposalConv.Decode(fullPB)) + require.Error(t, tampered.Verify(vs)) +} + +func TestProposalVerifyRejectsWrappedAppRoadWithoutPanic(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) + leader := leaderKey(committee, keys, vs.View()) + fp := utils.OrPanic1(NewProposal( + leader, + vs, + time.Now(), + oneLaneQCMap(rng, committee, keys, vs), + utils.None[*AppQC](), + )) + + proposalPB := ProposalConv.Encode(fp.Proposal().Msg()) + proposalPB.App = AppProposalConv.Encode(NewAppProposal( + 0, + utils.Max[RoadIndex](), + GenAppHash(rng), + ep.EpochIndex(), + )) + maliciousProposal := utils.OrPanic1(ProposalConv.Decode(proposalPB)) + fullPB := FullProposalConv.Encode(fp) + fullPB.ProposalV2 = SignedProposalConv.Encode(Sign(leader, maliciousProposal)) + malicious := utils.OrPanic1(FullProposalConv.Decode(fullPB)) + + require.Error(t, malicious.Verify(vs)) +} + +func TestProposalFallsBackWhenAppQCFromFuture(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + const split RoadIndex = 1 + prev := NewEpoch(0, RoadRange{First: 0, Next: split}, committee) + current := NewEpoch(1, RoadRange{First: split, Next: utils.Max[RoadIndex]()}, committee) + qc0 := BuildCommitQC(prev, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + // Same-epoch App on the justifying tipcut so BuildCommitQC keeps it (Prev absent there). + priorApp := makeAppQCFor(keys, qc0.GlobalRange().Next-1, qc0.Proposal().Index(), GenAppHash(rng), 1) + qc1 := BuildCommitQC(current, keys, utils.Some(qc0), nil, utils.Some(priorApp)) + require.True(t, qc1.Proposal().App().IsPresent(), "fixture CommitQC must carry App") + vs := ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(current, utils.Some(prev))} + leader := leaderKey(committee, keys, vs.View()) + lanes := oneLaneQCMap(rng, committee, keys, vs) + + future := makeAppQCFor(keys, vs.NextGlobalBlock(), vs.View().Index-1, GenAppHash(rng), 1) + fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(future))) + require.False(t, fp.appQC.IsPresent()) + app, ok := fp.Proposal().Msg().App().Get() + require.True(t, ok, "must fall back to CommitQC App, not clear to None") + want, _ := qc1.Proposal().App().Get() + require.Equal(t, want.AppHash(), app.AppHash()) + require.NoError(t, fp.Verify(vs)) +} + +func TestProposalDropsStaleCarriedAppAfterEpochAdvance(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + ep0 := NewEpoch(0, RoadRange{First: 0, Next: 1}, committee) + ep1 := NewEpoch(1, RoadRange{First: 1, Next: 2}, committee) + ep2 := NewEpoch(2, RoadRange{First: 2, Next: utils.Max[RoadIndex]()}, committee) + + qc0 := BuildCommitQC(ep0, keys, utils.None[*CommitQC](), nil, utils.None[*AppQC]()) + oldApp := makeAppQCFor( + keys, + qc0.GlobalRange().Next-1, + qc0.Proposal().Index(), + GenAppHash(rng), + ep0.EpochIndex(), + ) + qc1 := BuildCommitQC(ep1, keys, utils.Some(qc0), nil, utils.Some(oldApp)) + vs := ViewSpec{CommitQC: utils.Some(qc1), Epochs: NewEpochDuo(ep2, utils.Some(ep1))} + future := makeAppQCFor( + keys, + vs.NextGlobalBlock(), + vs.View().Index, + GenAppHash(rng), + ep2.EpochIndex(), + ) + + fp := utils.OrPanic1(NewProposal( + leaderKey(committee, keys, vs.View()), + vs, + time.Now(), + oneLaneQCMap(rng, committee, keys, vs), + utils.Some(future), + )) + require.False(t, fp.Proposal().Msg().App().IsPresent()) + require.False(t, fp.appQC.IsPresent()) + require.NoError(t, fp.Verify(vs)) +} + +func TestProposalVerifyRejectsAppQCRoadNotBeforeView(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + vs := viewSpecContiguousDuo(keys, committee) + leader := leaderKey(committee, keys, vs.View()) + view := vs.View().Index + lanes := oneLaneQCMap(rng, committee, keys, vs) + + // AppQC for the justifying CommitQC road (view-1) — kept and accepted. + fpOk := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.Some(makeAppQCFor(keys, 0, view-1, GenAppHash(rng), 1)))) + require.True(t, fpOk.appQC.IsPresent()) + require.NoError(t, fpOk.Verify(vs)) + + // Malformed same-road / ahead AppQC (global still in the past) is not + // sanitized by buildProposal; Verify rejects. + for _, road := range []RoadIndex{view, view + 1} { + bad := makeAppQCFor(keys, 0, road, GenAppHash(rng), 1) + base := utils.OrPanic1(NewProposal(leader, vs, time.Now(), lanes, utils.None[*AppQC]())) + baseMsg := base.proposal.Msg() + ranges := make([]*LaneRange, 0, len(baseMsg.laneRanges)) + for _, r := range baseMsg.laneRanges { + ranges = append(ranges, r) + } + tampered := &FullProposal{ + proposal: Sign(leader, newProposal(vs.View(), baseMsg.Timestamp(), ranges, utils.Some(bad.Proposal()), vs.NextGlobalBlock())), + laneQCs: base.laneQCs, + appQC: utils.Some(bad), + } + require.Error(t, tampered.Verify(vs), "road %d", road) + } +} + +func TestProposalVerifyRejectsInvalidAppQCSignature(t *testing.T) { + rng := utils.TestRng() + committee, keys := GenCommittee(rng, 4) + vs := viewSpecContiguousDuo(keys, committee) leader := leaderKey(committee, keys, vs.View()) appHash := GenAppHash(rng) - goodAppQC := makeAppQCFor(keys, ep.FirstBlock(), 0, appHash, ep.EpochIndex()) + goodAppQC := makeAppQCFor(keys, 0, 1, appHash, 1) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.Some(goodAppQC))) - // Swap in an AppQC signed by NON-committee keys (same hash). otherKeys := make([]SecretKey, len(keys)) for i := range otherKeys { otherKeys[i] = GenSecretKey(rng) } - badAppQC := makeAppQCFor(otherKeys, ep.FirstBlock(), 0, appHash, ep.EpochIndex()) + badAppQC := makeAppQCFor(otherKeys, 0, 1, appHash, 1) tamperedFP := &FullProposal{ proposal: fp.proposal, laneQCs: fp.laneQCs, appQC: utils.Some(badAppQC), } - err := tamperedFP.Verify(vs) - require.Error(t, err) + require.Error(t, tamperedFP.Verify(vs)) } func TestProposalVerifyRejectsLaneQCHeaderHashMismatch(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) ep := genFreshEpoch(rng, committee) - vs := ViewSpec{Epoch: ep} + vs := genesisViewSpec(rng, ep) proposerKey := leaderKey(committee, keys, vs.View()) lane := proposerKey.Public() @@ -750,7 +995,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 := genesisViewSpec(rng, ep) leader0 := leaderKey(committee, keys, vs0.View()) lane := committee.Leader(vs0.View()) laneQC0 := makeLaneQC(rng, committee, keys, lane, 0, GenBlockHeaderHash(rng)) @@ -771,7 +1016,7 @@ func TestProposalVerifyValidReproposal(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} + vs1 := withTimeoutQC(vs0, timeoutQC) require.Equal(t, View{Index: 0, Number: 1, EpochIndex: ep.EpochIndex()}, vs1.View()) leader1 := leaderKey(committee, keys, vs1.View()) @@ -788,7 +1033,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := genesisViewSpec(rng, ep) leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -804,7 +1049,7 @@ func TestProposalVerifyRejectsReproposalWithUnnecessaryData(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} + vs1 := withTimeoutQC(vs0, timeoutQC) leader1 := leaderKey(committee, keys, vs1.View()) // Create a valid reproposal, then tamper it with unnecessary laneQCs. @@ -827,7 +1072,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { ep := genFreshEpoch(rng, committee) // Build a PrepareQC at (0, 0). - vs0 := ViewSpec{Epoch: ep} + vs0 := genesisViewSpec(rng, ep) leader0 := leaderKey(committee, keys, vs0.View()) fp0 := utils.OrPanic1(NewProposal(leader0, vs0, time.Now(), oneLaneQCMap(rng, committee, keys, vs0), utils.None[*AppQC]())) @@ -843,7 +1088,7 @@ func TestProposalVerifyRejectsReproposalHashMismatch(t *testing.T) { } timeoutQC := NewTimeoutQC(timeoutVotes) - vs1 := ViewSpec{TimeoutQC: utils.Some(timeoutQC), Epoch: ep} + vs1 := withTimeoutQC(vs0, timeoutQC) leader1 := leaderKey(committee, keys, vs1.View()) // Build the valid reproposal, then tamper its timestamp to get a different hash. @@ -879,7 +1124,7 @@ func TestProposalVerifyRejectsInvalidTimeoutQCSignature(t *testing.T) { } badTimeoutQC := NewTimeoutQC(timeoutVotes) - vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epoch: ep} + vs := ViewSpec{TimeoutQC: utils.Some(badTimeoutQC), Epochs: EpochDuoForTest(ep)} leader := leaderKey(committee, keys, vs.View()) fp := utils.OrPanic1(NewProposal(leader, vs, time.Now(), oneLaneQCMap(rng, committee, keys, vs), utils.None[*AppQC]())) @@ -892,19 +1137,19 @@ func TestViewSpecViewStampsEpochIndex(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) epochIdx := EpochIndex(7) - ep := NewEpoch(epochIdx, OpenRoadRange(), time.Time{}, committee, 0) + ep := NewEpoch(epochIdx, RoadRange{First: 1, Next: utils.Max[RoadIndex]()}, committee) - // Without TimeoutQC: epoch index must come from vs.Epoch. - vs0 := ViewSpec{Epoch: ep} + // Without TimeoutQC: epoch index must come from vs.Epoch(). + vs0 := genesisViewSpec(rng, 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 := withTimeoutQC(vs0, tqc) if got := vs1.View().EpochIndex; got != epochIdx { t.Fatalf("TimeoutQC path: EpochIndex = %d, want %d", got, epochIdx) } diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index ffc3d5a73d..36c1df8681 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -12,10 +12,33 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// EpochDuoForTest builds a valid Prev|Current duo for tests. Prev is absent +// iff Current is epoch 0; otherwise a synthetic contiguous Prev is created. +func EpochDuoForTest(current *Epoch) EpochDuo { + if current.EpochIndex() == 0 { + return NewEpochDuo(current, utils.None[*Epoch]()) + } + first := current.RoadRange().First + if first == 0 { + panic(fmt.Sprintf("EpochDuoForTest: epoch %d with RoadRange.First==0 cannot host Prev", + current.EpochIndex())) + } + prev := NewEpoch( + current.EpochIndex()-1, + RoadRange{First: 0, Next: first}, + current.Committee(), + ) + return NewEpochDuo(current, utils.Some(prev)) +} + // BuildCommitQC builds a valid CommitQC from explicit lane QCs and an optional app QC. // If laneQCs is empty, a single 1-block LaneQC is synthesized so the tipcut is // non-empty (empty tipcuts are rejected by Proposal.Verify). // Use BuildFullCommitQC when you want random blocks generated automatically. +// +// For epoch>0, NewProposal requires an in-window App (Current or Current-1). If +// neither appQC nor prev's CommitQC App provides one, a Current-1 AppQC is +// synthesized on the prior tipcut road so fixtures keep working. func BuildCommitQC( epoch *Epoch, keys []SecretKey, @@ -23,10 +46,20 @@ func BuildCommitQC( laneQCs map[LaneID]*LaneQC, appQC utils.Option[*AppQC], ) *CommitQC { - vs := ViewSpec{CommitQC: prev, Epoch: epoch} + vs := ViewSpec{CommitQC: prev, Epochs: EpochDuoForTest(epoch)} if len(laneQCs) == 0 { laneQCs = oneBlockLaneQCMap(vs, keys) } + if epoch.EpochIndex() > 0 { + have := appInDuoWindow(AppOpt(ProposalOpt(prev)), epoch.EpochIndex()) + if qc, ok := appQC.Get(); ok { + ep := qc.Proposal().EpochIndex() + have = have || ep == epoch.EpochIndex() || ep == epoch.EpochIndex()-1 + } + if !have { + appQC = utils.Some(synthAppQCForPrevEpoch(epoch, keys, prev)) + } + } leader := epoch.Committee().Leader(vs.View()) var leaderKey SecretKey for _, k := range keys { @@ -43,9 +76,39 @@ func BuildCommitQC( return NewCommitQC(votes) } +func appInDuoWindow(app utils.Option[*AppProposal], want EpochIndex) bool { + a, ok := app.Get() + if !ok { + return false + } + ep := a.EpochIndex() + if ep == want { + return true + } + return want > 0 && ep == want-1 +} + +// synthAppQCForPrevEpoch builds a Current-1 AppQC anchored on the prior tipcut +// (or genesis global 0 when prev is absent). +func synthAppQCForPrevEpoch(epoch *Epoch, keys []SecretKey, prev utils.Option[*CommitQC]) *AppQC { + prevEp := epoch.EpochIndex() - 1 + var global GlobalBlockNumber + var road RoadIndex + if cqc, ok := prev.Get(); ok { + global = cqc.GlobalRange().Next - 1 + road = cqc.Proposal().Index() + } + p := NewAppProposal(global, road, AppHash{}, prevEp) + votes := make([]*Signed[*AppVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, Sign(k, NewAppVote(p))) + } + return NewAppQC(votes) +} + // oneBlockLaneQCMap builds a single LaneQC advancing the first committee lane by one block. func oneBlockLaneQCMap(vs ViewSpec, keys []SecretKey) map[LaneID]*LaneQC { - c := vs.Epoch.Committee() + c := vs.Epoch().Committee() lane := c.Lanes().At(0) n := LaneRangeOpt(vs.CommitQC, lane).Next() header := NewBlock(lane, n, BlockHeaderHash{}, &Payload{}).Header() @@ -289,16 +352,19 @@ func GenEpochIndex(rng utils.Rng) EpochIndex { } // GenEpochWithCommittee returns a random Epoch wrapping committee. -// epochIndex, firstBlock, timestamp, and Roads.First are randomized so that tests -// exercise epoch-binding checks rather than silently passing on zero values. +// epochIndex and Roads.First are randomized so that tests exercise +// epoch-binding checks rather than silently passing on zero values. +// When epochIndex > 0, First is at least 1 so EpochDuoForTest can place Prev. func GenEpochWithCommittee(rng utils.Rng, committee *Committee) *Epoch { + idx := GenEpochIndex(rng) first := RoadIndex(rng.Uint64() % 1000) + if idx > 0 && first == 0 { + first = RoadIndex(rng.Uint64()%999) + 1 + } return NewEpoch( - GenEpochIndex(rng), - RoadRange{First: first, Last: first + RoadIndex(rng.Uint64()%10000) + 10}, - utils.GenTimestamp(rng), + idx, + RoadRange{First: first, Next: first + RoadIndex(rng.Uint64()%10000) + 11}, committee, - GlobalBlockNumber(rng.Uint64()%1000000)+1, ) } @@ -325,22 +391,24 @@ func GenProposalAt(rng utils.Rng, view View) *Proposal { // ProposalAt returns a minimal non-empty Proposal at view, consistent with ep. // Includes a single 1-block lane range so Proposal.Verify accepts it (empty // tipcuts are forbidden). For tests that care about signature weight or epoch -// binding rather than real lane/app data. +// binding rather than real lane/app data. GlobalRange starts at 1. func ProposalAt(ep *Epoch, view View) *Proposal { view.EpochIndex = ep.EpochIndex() lane := ep.Committee().Lanes().At(0) header := NewBlock(lane, 0, BlockHeaderHash{}, &Payload{}).Header() - return newProposal(view, time.Time{}, []*LaneRange{NewLaneRange(lane, 0, utils.Some(header))}, utils.None[*AppProposal](), ep.FirstBlock()) + return newProposal(view, time.Time{}, []*LaneRange{NewLaneRange(lane, 0, utils.Some(header))}, utils.None[*AppProposal](), 1) } -// GenProposalForEpoch generates a Proposal at a specific view whose epochIndex, -// firstBlock, and lane IDs are all consistent with ep. Use in tests that verify -// QCs against a known Epoch. +// GenProposalForEpoch generates a Proposal at a specific view whose epochIndex +// and lane IDs are consistent with ep. Use in tests that verify QCs against a +// known Epoch. GlobalRange.First is randomized (floors live on ViewSpec/Registry, +// not Epoch). func GenProposalForEpoch(rng utils.Rng, ep *Epoch, view View) *Proposal { view.EpochIndex = ep.EpochIndex() c := ep.Committee() laneRanges := utils.GenSlice(rng, func(rng utils.Rng) *LaneRange { return GenLaneRangeFor(rng, c) }) - return newProposal(view, utils.GenTimestamp(rng), laneRanges, utils.Some(GenAppProposal(rng)), ep.FirstBlock()) + firstBlock := GlobalBlockNumber(rng.Uint64()%1000000) + 1 + return newProposal(view, utils.GenTimestamp(rng), laneRanges, utils.Some(GenAppProposal(rng)), firstBlock) } // GenAppHash generates a random AppHash. diff --git a/sei-tendermint/autobahn/types/types_test.go b/sei-tendermint/autobahn/types/types_test.go index b82de8462c..de05d0076d 100644 --- a/sei-tendermint/autobahn/types/types_test.go +++ b/sei-tendermint/autobahn/types/types_test.go @@ -144,7 +144,7 @@ func TestTimeoutQCConvDecode_EmptyVotesReturnsError(t *testing.T) { func TestNewTimeoutQC_MixedPrepareQCs(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) + ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), committee) view := View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()} pqc := makePrepareQC(keys, NewPrepareVote(ProposalAt(ep, view))) @@ -174,7 +174,7 @@ func TestNewTimeoutQC_MixedPrepareQCs(t *testing.T) { func TestNewTimeoutQC_AllNone(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) + ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), committee) view := View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()} votes := make([]*FullTimeoutVote, len(keys)) @@ -196,7 +196,7 @@ func TestNewTimeoutQC_AllNone(t *testing.T) { func TestTimeoutQCVerify_HighestPrepareQCSelected(t *testing.T) { rng := utils.TestRng() committee, keys := GenCommittee(rng, 4) - ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) + ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), committee) view := View{Index: 0, Number: 5, EpochIndex: ep.EpochIndex()} makePQCAt := func(vn ViewNumber) *PrepareQC { diff --git a/sei-tendermint/autobahn/types/wireguard_test.go b/sei-tendermint/autobahn/types/wireguard_test.go index cbebd32535..d1b7f0278e 100644 --- a/sei-tendermint/autobahn/types/wireguard_test.go +++ b/sei-tendermint/autobahn/types/wireguard_test.go @@ -197,7 +197,7 @@ func TestFullProposalWireguardAcceptsMaxValidators(t *testing.T) { } proposal, err := NewProposal( secretKeyFor(keys, committee.Leader(View{})), - ViewSpec{Epoch: NewEpoch(0, OpenRoadRange(), time.Time{}, committee, 0)}, + ViewSpec{Epochs: NewEpochDuo(NewEpoch(0, OpenRoadRange(), committee), utils.None[*Epoch]())}, time.Unix(1, 2), laneQCs, utils.None[*AppQC](), diff --git a/sei-tendermint/internal/autobahn/avail/app.go b/sei-tendermint/internal/autobahn/avail/app.go new file mode 100644 index 0000000000..bf68ed6725 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/app.go @@ -0,0 +1,161 @@ +package avail + +import ( + "context" + "errors" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// AppTip is avail's live App tip: AppQC + justifying CommitQC + that epoch +// (Prev when App lags Current). App owns and advances this tip; prune/persist +// only snapshots it for the durability watermark. +type AppTip struct { + AppQC *types.AppQC + CommitQC *types.CommitQC + Epoch *types.Epoch +} + +// Next is one past the AppQC road index (types.NextOpt). +func (t *AppTip) Next() types.RoadIndex { return t.AppQC.Next() } + +// appProgress owns the App tip and AppVote accumulators. +type appProgress struct { + tip utils.Option[*AppTip] + votes *queue[types.GlobalBlockNumber, appVotes] +} + +// LastAppQC returns the AppQC peel of the live App tip. +func (s *State) LastAppQC() utils.Option[*types.AppQC] { + for inner := range s.inner.Lock() { + if tip, ok := inner.app.tip.Get(); ok { + return utils.Some(tip.AppQC) + } + return utils.None[*types.AppQC]() + } + panic("unreachable") +} + +// WaitForAppQC waits until the App tip is at idx or higher. +// Returns the tip AppQC and its matching CommitQC. +func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, *types.CommitQC, error) { + for inner, ctrl := range s.inner.Lock() { + for { + if tip, ok := inner.app.tip.Get(); ok { + if x := tip.AppQC.Proposal().RoadIndex(); x >= idx { + return tip.AppQC, tip.CommitQC, nil + } + } + if err := ctrl.Wait(ctx); err != nil { + return nil, nil, err + } + } + } + panic("unreachable") +} + +// PushAppVote pushes an AppVote to the state. +// Far-future roads park until CommitQC tip and App admit window catch up; +// behind-window votes soft-drop. +func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote]) error { + idx := v.Msg().Proposal().RoadIndex() + if err := s.waitForCommitQC(ctx, idx); err != nil { + return err + } + epoch, err := s.waitForAppEpoch(ctx, idx) + if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } + return err + } + if got, want := v.Msg().Proposal().EpochIndex(), epoch.EpochIndex(); got != want { + return fmt.Errorf("appVote epoch_index %d, want %d", got, want) + } + qc, err := s.CommitQC(ctx, idx) + if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } + return err + } + if err := v.Msg().Proposal().Verify(qc); err != nil { + return fmt.Errorf("invalid vote: %w", err) + } + committee := epoch.Committee() + if err := v.VerifySig(committee); err != nil { + return fmt.Errorf("v.VerifySig(): %w", err) + } + for inner, ctrl := range s.inner.Lock() { + if idx < types.NextOpt(inner.app.tip) { + return nil + } + n := v.Msg().Proposal().GlobalNumber() + q := inner.app.votes + for q.next <= n { + q.pushBack(newAppVotes()) + } + appQC, ok := q.q[n].pushVote(committee, v) + if !ok { + return nil + } + updated, err := inner.pushAppTip(&AppTip{AppQC: appQC, CommitQC: qc, Epoch: epoch}) + if err != nil { + return err + } + if updated { + ctrl.Updated() + } + } + return nil +} + +// PushAppQC requires a justifying CommitQC. +// Admits only in the App window (Current|App-tip/Prev); parks ahead, soft-drops +// behind. Advances the App tip (and thus the prune watermark) up to AppQC. +func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC, commitQC *types.CommitQC) error { + for inner := range s.inner.Lock() { + if types.NextOpt(inner.app.tip) >= appQC.Next() { + return nil + } + } + if appQC.Proposal().RoadIndex() != commitQC.Proposal().Index() { + return fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", appQC.Proposal().RoadIndex(), commitQC.Proposal().Index()) + } + if got, want := appQC.Proposal().EpochIndex(), commitQC.Proposal().EpochIndex(); got != want { + return fmt.Errorf("appQC epoch_index %d != commitQC epoch_index %d", got, want) + } + if !commitQC.GlobalRange().Has(appQC.Proposal().GlobalNumber()) { + return fmt.Errorf("appQC GlobalNumber not in commitQC range") + } + idx := commitQC.Proposal().Index() + epoch, err := s.waitForAppEpoch(ctx, idx) + if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } + return err + } + if got, want := appQC.Proposal().EpochIndex(), epoch.EpochIndex(); got != want { + return fmt.Errorf("appQC epoch_index %d, want %d", got, want) + } + if err := appQC.Verify(epoch); err != nil { + return fmt.Errorf("appQC.Verify(): %w", err) + } + if err := commitQC.Verify(epoch); err != nil { + return fmt.Errorf("commitQC.Verify(): %w", err) + } + tip := &AppTip{AppQC: appQC, CommitQC: commitQC, Epoch: epoch} + for inner, ctrl := range s.inner.Lock() { + updated, err := inner.pushAppTip(tip) + if err != nil { + return err + } + if updated { + ctrl.Updated() + } + } + return nil +} diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index ba84945e1e..cfe8315248 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -2,42 +2,99 @@ package avail import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// laneVoteSet holds weight toward LaneQC for one header hash under Current. +type laneVoteSet struct { + weight uint64 + votes []*types.Signed[*types.LaneVote] + header *types.BlockHeader + qc utils.Option[*types.LaneQC] +} + +// add credits vote weight until quorum. Returns a newly formed LaneQC iff this +// vote crosses quorum and caches it for subsequent readers. +func (s *laneVoteSet) add(weight, quorum uint64, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { + if s.qc.IsPresent() { + 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 = utils.Some(types.NewLaneQC(s.votes)) + return s.qc +} + +func (s *laneVoteSet) laneQC() utils.Option[*types.LaneQC] { + return s.qc +} + +// blockVotes credits lane votes under the Current committee only. +// Each header hash may form its own LaneQC. 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{ +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 credits vote under ep (Current). Zero-weight → drop (not retained). +// Callers VerifySig first; after a lock release, Weight==0 is still a silent drop. +func (bv *blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) utils.Option[*types.LaneQC] { k := vote.Key() - h := vote.Msg().Header().Hash() if _, ok := bv.byKey[k]; ok { - return nil, false + return utils.None[*types.LaneQC]() + } + w := ep.Committee().Weight(k) + if w == 0 { + 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 } - if byHash.weight >= c.LaneQuorum() { - return nil, false + return set.add(w, ep.Committee().LaneQuorum(), vote) +} + +// reweight recomputes already-stored votes under new Current after advanceEpoch. +// Zero-weight signers are removed from byKey. Callers wake waiters via +// ctrl.Updated() after advanceEpoch (not via a return flag). +func (bv *blockVotes) reweight(c *types.Committee) { + clear(bv.byHash) + quorum := c.LaneQuorum() + for k, vote := range bv.byKey { + w := c.Weight(k) + if w == 0 { + delete(bv.byKey, k) + continue + } + h := vote.Msg().Header().Hash() + set, ok := bv.byHash[h] + if !ok { + set = &laneVoteSet{header: vote.Msg().Header()} + bv.byHash[h] = set + } + set.add(w, quorum, vote) } - byHash.weight += c.Weight(k) - byHash.votes = append(byHash.votes, vote) - if byHash.weight >= c.LaneQuorum() { - return types.NewLaneQC(byHash.votes), true +} + +func (bv *blockVotes) laneQC() utils.Option[*types.LaneQC] { + for _, set := range bv.byHash { + if qc, ok := set.laneQC().Get(); ok { + return utils.Some(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..8b62a75a97 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -0,0 +1,191 @@ +package avail + +import ( + "testing" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/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, c) +} + +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.True(t, set.add(1, 2, mkVote()).IsPresent()) + require.Equal(t, uint64(2), set.weight) + require.Len(t, set.votes, 2) + + require.False(t, set.add(1, 2, mkVote()).IsPresent(), "already at quorum: no further credit") + require.Equal(t, uint64(2), set.weight) + require.Len(t, set.votes, 2) + + heavy := &laneVoteSet{} + require.True(t, heavy.add(3, 2, mkVote()).IsPresent()) + require.Equal(t, uint64(3), heavy.weight) + require.Len(t, heavy.votes, 1) +} + +func TestPushVote_ZeroWeightNotRetained(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.NotContains(t, bv.byKey, keyZ.Public()) + require.Empty(t, bv.byHash) +} + +func TestPushVote_CurrentCommitteeOnly(t *testing.T) { + rng := utils.TestRng() + keyA := types.GenSecretKey(rng) + keyB := types.GenSecretKey(rng) + keyC := types.GenSecretKey(rng) + keyD := types.GenSecretKey(rng) + keyE := types.GenSecretKey(rng) + + // 4×1 → Faulty=1, LaneQuorum=2. + ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, + }) + ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, keyE.Public(): 1, + }) + + lane := keyA.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + h := header.Hash() + + bv := newBlockVotes() + require.False(t, bv.pushVote(ep0, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) + require.NotContains(t, bv.byKey, keyE.Public()) + + require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) + qc, ok := bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).Get() + require.True(t, ok) + gotQC, ok := bv.byHash[h].laneQC().Get() + require.True(t, ok) + require.True(t, qc == gotQC, "formed LaneQC should be cached") + require.Equal(t, qc.Header().Hash(), gotQC.Header().Hash()) + + // ep1: Faulty=(5-1)/3=1, LaneQuorum=2; A+B still form quorum under new weights. + bv.reweight(ep1.Committee()) + require.True(t, bv.laneQC().IsPresent()) + require.True(t, bv.byHash[h].laneQC().IsPresent()) + require.False(t, bv.pushVote(ep1, types.Sign(keyE, types.NewLaneVote(header))).IsPresent()) + require.Contains(t, bv.byKey, keyE.Public()) + require.Equal(t, header, bv.byHash[h].header) +} + +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) +} + +func TestPushVote_ReweightAfterAdvance(t *testing.T) { + rng := utils.TestRng() + keyA := types.GenSecretKey(rng) + keyB := types.GenSecretKey(rng) + keyC := types.GenSecretKey(rng) + keyD := types.GenSecretKey(rng) + + // 4×1 → LaneQuorum=2. + ep0 := makeVoteEpoch(0, map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, + }) + // Only A remains → Faulty=0, LaneQuorum=1; A alone forms QC under new Current. + ep1 := makeVoteEpoch(1, map[types.PublicKey]uint64{keyA.Public(): 1}) + + lane := keyA.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + h := header.Hash() + staleHeader := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + + bv := newBlockVotes() + require.False(t, bv.pushVote(ep0, types.Sign(keyA, types.NewLaneVote(header))).IsPresent()) + require.True(t, bv.pushVote(ep0, types.Sign(keyB, types.NewLaneVote(header))).IsPresent()) + require.False(t, bv.pushVote(ep0, types.Sign(keyC, types.NewLaneVote(staleHeader))).IsPresent()) + + bv.reweight(ep1.Committee()) + require.Equal(t, uint64(1), bv.byHash[h].weight) + require.Len(t, bv.byHash[h].votes, 1) + require.Equal(t, keyA.Public(), bv.byHash[h].votes[0].Key()) + require.True(t, bv.byHash[h].laneQC().IsPresent()) + require.True(t, bv.laneQC().IsPresent()) + require.NotContains(t, bv.byKey, keyB.Public(), "zero-weight signer removed from byKey") + require.NotContains(t, bv.byHash, staleHeader.Hash(), "hash with no current-committee votes removed") +} + +func TestPushVote_CompetingHashesBothFormQC(t *testing.T) { + rng := utils.TestRng() + keyA := types.GenSecretKey(rng) + keyB := types.GenSecretKey(rng) + keyC := types.GenSecretKey(rng) + keyD := types.GenSecretKey(rng) + + // 4×1 → LaneQuorum=2. + ep := makeVoteEpoch(0, map[types.PublicKey]uint64{ + keyA.Public(): 1, keyB.Public(): 1, keyC.Public(): 1, keyD.Public(): 1, + }) + lane := keyA.Public() + header1 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + header2 := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + require.NotEqual(t, header1.Hash(), header2.Hash()) + + bv := newBlockVotes() + require.False(t, bv.pushVote(ep, types.Sign(keyA, types.NewLaneVote(header1))).IsPresent()) + require.True(t, bv.pushVote(ep, types.Sign(keyB, types.NewLaneVote(header1))).IsPresent()) + require.True(t, bv.byHash[header1.Hash()].laneQC().IsPresent()) + + // Competing hash forms its own LaneQC; both stay. + require.False(t, bv.pushVote(ep, types.Sign(keyC, types.NewLaneVote(header2))).IsPresent()) + require.True(t, bv.pushVote(ep, types.Sign(keyD, types.NewLaneVote(header2))).IsPresent()) + require.True(t, bv.byHash[header2.Hash()].laneQC().IsPresent()) + require.Equal(t, header2, bv.byHash[header2.Hash()].header) + require.Len(t, bv.byHash[header2.Hash()].votes, 2) + require.True(t, bv.byHash[header1.Hash()].laneQC().IsPresent()) + require.True(t, bv.laneQC().IsPresent()) +} diff --git a/sei-tendermint/internal/autobahn/avail/commit.go b/sei-tendermint/internal/autobahn/avail/commit.go new file mode 100644 index 0000000000..10c3c1c790 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/commit.go @@ -0,0 +1,101 @@ +package avail + +import ( + "context" + "errors" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// waitForCommitQC waits until the durable CommitQC tip has advanced past idx. +func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error { + _, err := s.LastCommitQC().Wait(ctx, func(qc utils.Option[*types.CommitQC]) bool { + return types.NextIndexOpt(qc) > idx + }) + return err +} + +// waitForCommitQCAndEpoch waits for the CommitQC at idx and returns it with the +// epoch needed to verify it: Current when the QC is in Current, otherwise the +// App-tip epoch (Prev). +func (s *State) waitForCommitQCAndEpoch(ctx context.Context, idx types.RoadIndex) (*types.CommitQC, *types.Epoch, error) { + if err := s.waitForCommitQC(ctx, idx); err != nil { + return nil, nil, err + } + for inner := range s.inner.Lock() { + if idx < inner.commits.qcs.first { + return nil, nil, types.ErrPruned + } + qc := inner.commits.qcs.q[idx] + cur := inner.epoch.Load() + if cur.EpochIndex() == qc.Proposal().EpochIndex() { + return qc, cur, nil + } + tip, ok := inner.app.tip.Get() + if !ok || tip.Epoch == nil { + return nil, nil, fmt.Errorf("commitQC epoch %d needs App-tip epoch", qc.Proposal().EpochIndex()) + } + return qc, tip.Epoch, nil + } + panic("unreachable") +} + +// CommitQC returns the CommitQC for the given index. +func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.CommitQC, error) { + if err := s.waitForCommitQC(ctx, idx); err != nil { + return nil, err + } + for inner := range s.inner.Lock() { + if idx < inner.commits.qcs.first { + return nil, types.ErrPruned + } + return inner.commits.qcs.q[idx], nil + } + panic("unreachable") +} + +// PushCommitQC admits qc for Current only (too early waits; stale drops). +// N+1 CommitQCs park on Epoch until runAdvanceEpoch slides Current. +// Seal leashes (App anchor + registry N+1) gate that advance, not this admit. +func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { + if i := qc.Proposal().Index(); i > 0 { + if err := s.waitForCommitQC(ctx, i-1); err != nil { + return err + } + } + epoch, err := s.Epoch(ctx, qc.Proposal().EpochIndex()) + if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } + return err + } + if err := qc.Verify(epoch); err != nil { + return fmt.Errorf("qc.Verify(): %w", err) + } + for inner, ctrl := range s.inner.Lock() { + if inner.commits.push(qc) { + ctrl.Updated() + } + } + return nil +} + +// fullCommitQC returns the FullCommitQC for road n. +func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, error) { + qc, epoch, err := s.waitForCommitQCAndEpoch(ctx, n) + if err != nil { + return nil, err + } + var commitHeaders []*types.BlockHeader + for lane := range epoch.Committee().Lanes().All() { + headers, err := s.headers(ctx, qc.LaneRange(lane)) + if err != nil { + return nil, err + } + commitHeaders = append(commitHeaders, headers...) + } + return types.NewFullCommitQC(qc, commitHeaders), nil +} diff --git a/sei-tendermint/internal/autobahn/avail/commit_progress.go b/sei-tendermint/internal/autobahn/avail/commit_progress.go new file mode 100644 index 0000000000..b1ea6b832b --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/commit_progress.go @@ -0,0 +1,32 @@ +package avail + +import ( + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail/metrics" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// commitProgress owns the admitted CommitQC log and its durable tip. +// push advances only the admitted queue; only restore and markPersisted +// write persistedCommitQC. +type commitProgress struct { + qcs *queue[types.RoadIndex, *types.CommitQC] + persistedCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] +} + +// push inserts qc at qcs.next. Returns false if idx is not the tip +// (race / already applied). Does not write persistedCommitQC. +func (c *commitProgress) push(qc *types.CommitQC) bool { + idx := qc.Proposal().Index() + if idx != c.qcs.next { + return false + } + c.qcs.pushBack(qc) + metrics.ObserveCommitQC(qc) + return true +} + +// markPersisted publishes the latest durably persisted CommitQC. +func (c *commitProgress) markPersisted(qc *types.CommitQC) { + c.persistedCommitQC.Store(utils.Some(qc)) +} diff --git a/sei-tendermint/internal/autobahn/avail/conv_test.go b/sei-tendermint/internal/autobahn/avail/conv_test.go index ca753e5ee5..0d7a507cef 100644 --- a/sei-tendermint/internal/autobahn/avail/conv_test.go +++ b/sei-tendermint/internal/autobahn/avail/conv_test.go @@ -10,22 +10,22 @@ import ( "google.golang.org/protobuf/proto" ) -func TestPruneAnchorConv(t *testing.T) { +func TestAppTipConv(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) 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(registry.LatestEpoch(), keys, utils.None[*types.CommitQC](), laneQCs, utils.None[*types.AppQC]()) + commitQC := makeCommitQC(registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC](), laneQCs, utils.None[*types.AppQC]()) appProposal := types.NewAppProposal(commitQC.GlobalRange().First, commitQC.Proposal().Index(), types.GenAppHash(rng), commitQC.Proposal().EpochIndex()) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - anchor := &PruneAnchor{AppQC: appQC, CommitQC: commitQC} - pb1 := PruneAnchorConv.Encode(anchor) - decoded, err := PruneAnchorConv.Decode(pb1) + anchor := &AppTip{AppQC: appQC, CommitQC: commitQC} + pb1 := AppTipConv.Encode(anchor) + decoded, err := AppTipConv.Decode(pb1) require.NoError(t, err) - require.True(t, proto.Equal(pb1, PruneAnchorConv.Encode(decoded))) + require.True(t, proto.Equal(pb1, AppTipConv.Encode(decoded))) } diff --git a/sei-tendermint/internal/autobahn/avail/epoch_transition.go b/sei-tendermint/internal/autobahn/avail/epoch_transition.go new file mode 100644 index 0000000000..3c67834f51 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/epoch_transition.go @@ -0,0 +1,79 @@ +package avail + +import ( + "context" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" +) + +// Epoch waits until avail Current reaches exactly epoch i. +// If Current has already moved past i, returns ErrPruned. +func (s *State) Epoch(ctx context.Context, i types.EpochIndex) (*types.Epoch, error) { + epoch, err := s.epoch.Wait(ctx, func(epoch *types.Epoch) bool { + return i <= epoch.EpochIndex() + }) + if err != nil { + return nil, err + } + if epoch.EpochIndex() != i { + return nil, types.ErrPruned + } + return epoch, nil +} + +// waitForAppEpoch waits until roadIdx is in the App admit window: Current, or +// the App-tip epoch when App lags (Prev). Soft-returns ErrPruned when behind +// that window (caller drops). Parks while roadIdx is still ahead of Current. +func (s *State) waitForAppEpoch(ctx context.Context, roadIdx types.RoadIndex) (*types.Epoch, error) { + for { + if _, err := s.epoch.Wait(ctx, func(cur *types.Epoch) bool { + return roadIdx < cur.RoadRange().Next + }); err != nil { + return nil, err + } + for inner := range s.inner.Lock() { + cur := inner.epoch.Load() + if roadIdx >= cur.RoadRange().Next { + break // Current moved; retry Wait + } + if cur.RoadRange().Has(roadIdx) { + return cur, nil + } + if tip, ok := inner.app.tip.Get(); ok && tip.Epoch != nil && tip.Epoch.RoadRange().Has(roadIdx) { + return tip.Epoch, nil + } + return nil, types.ErrPruned + } + } +} + +// runAdvanceEpoch is the sole post-construction writer of Current. Seal leashes: +// - prune: App tip epoch >= Current +// - execution: registry has Current+1 +// +// N+1 CommitQCs park on Epoch(N+1) until this advances Current. +func (s *State) runAdvanceEpoch(ctx context.Context) error { + return s.epoch.Iter(ctx, func(ctx context.Context, epoch *types.Epoch) error { + for inner, ctrl := range s.inner.Lock() { + if err := ctrl.WaitUntil(ctx, func() bool { + if inner.commits.qcs.next < epoch.RoadRange().Next { + return false + } + tip, ok := inner.app.tip.Get() + return ok && tip.Epoch != nil && tip.Epoch.EpochIndex() >= epoch.EpochIndex() + }); err != nil { + return err + } + } + next, err := s.data.Registry().WaitForEpoch(ctx, epoch.EpochIndex()+1) + if err != nil { + return err + } + for inner, ctrl := range s.inner.Lock() { + if inner.advanceEpoch(next) { + ctrl.Updated() + } + } + return nil + }) +} diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index f25d8bf461..decf383d2e 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -7,122 +7,142 @@ 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. +// must be added via lanes.getOrInsert (blocks, votes, and durable cursors live +// on laneState). Currently lanes are initialized once in newInner from the +// start EpochDuo. BlockPersister creates lane WALs lazily inside +// MaybePruneAndPersistLane, but the new member must also appear in +// inner.lanes before the next persist cycle. type inner struct { - epoch *types.Epoch - latestAppQC utils.Option[*types.AppQC] - latestCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] - 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). - // - // 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 + epoch utils.AtomicSend[*types.Epoch] + app appProgress + commits commitProgress + lanes laneCollection } // loadedAvailState holds data loaded from disk on restart. -// pruneAnchor is the decoded prune anchor (if any). -// commitQCs and blocks are pre-filtered: stale entries below the -// anchor have already been removed by loadPersistedState. +// appTip is the decoded App tip watermark (if any); Epoch is filled in newInner. +// commitQCs and blocks are pre-filtered: stale entries below the tip have +// already been removed by loadPersistedState. // commitQCs are sorted by road index; blocks are sorted by number per lane. // newInner requires both to be contiguous and returns an error on gaps. type loadedAvailState struct { - pruneAnchor utils.Option[*PruneAnchor] - commitQCs []persist.LoadedCommitQC - blocks map[types.LaneID][]persist.LoadedBlock + appTip utils.Option[*AppTip] + commitQCs []persist.LoadedCommitQC + blocks map[types.LaneID][]persist.LoadedBlock } -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 App tip when the WAL lags. +func (ls *loadedAvailState) nextCommitQC() types.RoadIndex { + tip := types.RoadIndex(0) + if n := len(ls.commitQCs); n > 0 { + tip = ls.commitQCs[n-1].Index + 1 + } + if appTip, ok := ls.appTip.Get(); ok { + tip = max(tip, appTip.CommitQC.Proposal().Index()+1) } + return tip +} - 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()) +func newInner(registry *epoch.Registry, commitTip types.RoadIndex, loaded utils.Option[*loadedAvailState]) (*inner, error) { + startEpochDuo, err := registry.DuoAt(commitTip) + if err != nil { + return nil, fmt.Errorf("DuoAt(%d): %w", commitTip, err) + } + lanes := map[types.LaneID]*laneState{} + // TODO(lane-id): also seed Prev lanes before pruning so restart applies the + // anchor boundary to them (today only Current is pre-created; Prev lanes + // appear later via WAL getOrInsert and miss prune). Next Lane ID PR. + for lane := range startEpochDuo.Current.Committee().Lanes().All() { + lanes[lane] = newLaneState() + } + i := &inner{ + epoch: utils.NewAtomicSend(startEpochDuo.Current), + app: appProgress{ + tip: utils.None[*AppTip](), + votes: newQueue[types.GlobalBlockNumber, appVotes](), + }, + commits: commitProgress{ + qcs: newQueue[types.RoadIndex, *types.CommitQC](), + persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + }, + lanes: laneCollection{byID: lanes}, + } l, ok := loaded.Get() if !ok { + if startEpochDuo.Current.EpochIndex() > 0 { + return nil, fmt.Errorf("app tip required for epoch %d", startEpochDuo.Current.EpochIndex()) + } + i.app.votes.prune(registry.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(). - if anchor, ok := l.pruneAnchor.Get(); ok { - logger.Info("loaded persisted prune anchor", - slog.Uint64("roadIndex", uint64(anchor.AppQC.Proposal().RoadIndex())), - slog.Uint64("globalNumber", uint64(anchor.AppQC.Proposal().GlobalNumber())), + // Apply the persisted App tip first. It advances all queue boundaries, + // retains the tip CommitQC, and sets app.votes.first from that CommitQC. + if tip, ok := l.appTip.Get(); ok { + logger.Info("loaded persisted app tip", + slog.Uint64("roadIndex", uint64(tip.AppQC.Proposal().RoadIndex())), + slog.Uint64("globalNumber", uint64(tip.AppQC.Proposal().GlobalNumber())), ) - // 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 { - return nil, fmt.Errorf("prune: %w", err) + if err := verifyCommitQCInDuo(startEpochDuo, tip.CommitQC); err != nil { + return nil, fmt.Errorf("load app-tip CommitQC: %w", err) + } + // Persisted proto does not yet carry Epoch; recover from the restore duo + // so avail stays self-contained after load. + if tip.Epoch == nil { + ep, err := startEpochDuo.ByRoad(tip.CommitQC.Proposal().Index()) + if err != nil { + return nil, fmt.Errorf("load app-tip Epoch: %w", err) + } + tip.Epoch = ep } - for lane := range i.blocks { - i.persistedBlockStart[lane] = anchor.CommitQC.LaneRange(lane).First() + if _, err := i.pushAppTip(tip); err != nil { + return nil, fmt.Errorf("push app tip: %w", err) } + for lane, ls := range i.lanes.byID { + ls.durable.persistedBlockFirst = tip.CommitQC.LaneRange(lane).First() + } + } else if startEpochDuo.Current.EpochIndex() == 0 { + // No tip: floor app votes at genesis (registry), not tip Current — + // live advanceEpoch also leaves app.votes at the genesis floor. + i.app.votes.prune(registry.FirstBlock()) + } else { + return nil, fmt.Errorf("app tip 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. The prune anchor may have already pushed the + // anchor's CommitQC, so skip entries below commits.qcs.next. + // Epoch must already be seeded. for _, lqc := range l.commitQCs { - if lqc.Index < i.commitQCs.next { + if lqc.Index < i.commits.qcs.next { continue } - if lqc.Index != i.commitQCs.next { - return nil, fmt.Errorf("non-contiguous persisted commitQCs: expected %d, got %d", i.commitQCs.next, lqc.Index) + if lqc.Index != i.commits.qcs.next { + return nil, fmt.Errorf("non-contiguous persisted commitQCs: expected %d, got %d", i.commits.qcs.next, lqc.Index) + } + if err := verifyCommitQCInDuo(startEpochDuo, lqc.QC); err != nil { + return nil, fmt.Errorf("load CommitQC %d: %w", lqc.Index, err) } - i.commitQCs.pushBack(lqc.QC) + i.commits.qcs.pushBack(lqc.QC) } - if i.commitQCs.next > i.commitQCs.first { - i.latestCommitQC.Store(utils.Some(i.commitQCs.q[i.commitQCs.next-1])) + if i.commits.qcs.next > i.commits.qcs.first { + i.commits.markPersisted(i.commits.qcs.q[i.commits.qcs.next-1]) } - // Restore 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 blocks; create queues for any WAL lane (including outside Current). + // Old lanes are retained until lane-expiry (TODO). for lane, bs := range l.blocks { - q, ok := i.blocks[lane] - if !ok || len(bs) == 0 { + if len(bs) == 0 { continue } + ls := i.lanes.getOrInsert(lane) + q := ls.blocks var lastHash types.BlockHeaderHash for j, b := range bs { if q.Len() >= BlocksPerLane { @@ -140,49 +160,72 @@ 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.durable.persistedBlockNext = 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 +// verifyCommitQCInDuo verifies qc against startEpochDuo (Prev|Current at restore). +func verifyCommitQCInDuo(duo types.EpochDuo, qc *types.CommitQC) error { + ep, err := duo.ByRoad(qc.Proposal().Index()) + if err != nil { + return fmt.Errorf("epoch lookup: %w", err) + } + return qc.Verify(ep) +} + +// advanceEpoch installs Current. Sole post-construction writer is +// runAdvanceEpoch (after commit tip + seal leashes). +// Adds Current lanes; does not delete old lanes (TODO(lane-expiry)). +func (i *inner) advanceEpoch(epoch *types.Epoch) bool { + if i.epoch.Load().EpochIndex() >= epoch.EpochIndex() { + return false + } + c := epoch.Committee() + for lane := range c.Lanes().All() { + i.lanes.getOrInsert(lane) + } + for _, ls := range i.lanes.byID { + for n := ls.votes.first; n < ls.votes.next; n++ { + ls.votes.q[n].reweight(c) } } - return nil, false + i.epoch.Store(epoch) + return true } -// prune advances the state to account for a new AppQC/CommitQC pair. -// Returns true if pruning occurred, false if the QC was stale. -func (i *inner) prune(c *types.Committee, appQC *types.AppQC, commitQC *types.CommitQC) (bool, error) { +// pushAppTip advances queue boundaries for an App tip (AppQC + matching +// CommitQC + Epoch), retaining the CommitQC when it is the next tip. +// Returns true when the tip advanced, or false when it was stale. +// Cross-progress orchestrator: touches app, commits, and lanes. +func (i *inner) pushAppTip(tip *AppTip) (bool, error) { + appQC := tip.AppQC + commitQC := tip.CommitQC + if tip.Epoch == nil { + return false, fmt.Errorf("app tip missing Epoch") + } idx := appQC.Proposal().RoadIndex() if idx != commitQC.Proposal().Index() { return false, fmt.Errorf("mismatched QCs: appQC index %v, commitQC index %v", idx, commitQC.Proposal().Index()) } - if idx < types.NextOpt(i.latestAppQC) { + if got, want := tip.Epoch.EpochIndex(), appQC.Proposal().EpochIndex(); got != want { + return false, fmt.Errorf("app tip epoch %d != appQC epoch %d", got, want) + } + if idx < types.NextOpt(i.app.tip) { return false, nil } - i.latestAppQC = utils.Some(appQC) + i.app.tip = utils.Some(tip) 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 { + i.commits.qcs.prune(idx) + i.commits.push(commitQC) + i.app.votes.prune(commitQC.GlobalRange().First) + for lane, ls := range i.lanes.byID { lr := commitQC.LaneRange(lane) - 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()) + ls.durable.floorNext(lr.First()) } return true, nil } diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index a6deb0a076..e467ccede0 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -19,7 +19,9 @@ func newTestDataState(cfg *data.Config) *data.State { func TestPruneMismatchedIndices(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep, err := registry.EpochAt(0) + require.NoError(t, err) makeCommitQC := func(prev utils.Option[*types.CommitQC]) *types.CommitQC { l := keys[0].Public() @@ -28,7 +30,7 @@ func TestPruneMismatchedIndices(t *testing.T) { lqcs := map[types.LaneID]*types.LaneQC{ l: types.NewLaneQC(makeLaneVotes(keys, b.Header())), } - return makeCommitQC(registry.LatestEpoch(), keys, prev, lqcs, utils.None[*types.AppQC]()) + return makeCommitQC(ep, keys, prev, lqcs, utils.None[*types.AppQC]()) } makeAppQC := func(qcForRange *types.CommitQC, qcForIndex *types.CommitQC) *types.AppQC { gr := qcForRange.GlobalRange() @@ -44,25 +46,24 @@ func TestPruneMismatchedIndices(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) state, err := NewState(keys[0], ds, utils.Some(t.TempDir())) require.NoError(t, err) - require.Error(t, state.PushAppQC(makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") - require.Error(t, state.PushAppQC(makeAppQC(qc1, qc0), qc1), "good range, bad index should fail") - require.Error(t, state.PushAppQC(makeAppQC(qc0, qc1), qc1), "bad range, good index should fail") - require.NoError(t, state.PushAppQC(makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc1, qc0), qc1), "good range, bad index should fail") + require.Error(t, state.PushAppQC(t.Context(), makeAppQC(qc0, qc1), qc1), "bad range, good index should fail") + require.NoError(t, state.PushAppQC(t.Context(), makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") t.Logf("test inner.prune") ds = newTestDataState(&data.Config{Registry: registry}) state, err = NewState(keys[0], ds, utils.Some(t.TempDir())) require.NoError(t, err) for inner := range state.inner.Lock() { - _, err := inner.prune(registry.LatestEpoch().Committee(), makeAppQC(qc1, qc0), qc1) + _, err := inner.pushAppTip(&AppTip{AppQC: makeAppQC(qc1, qc0), CommitQC: qc1, Epoch: ep}) 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) + require.False(t, inner.app.tip.IsPresent(), "anchor should not have been updated") + _, err = inner.pushAppTip(&AppTip{AppQC: makeAppQC(qc1, qc1), CommitQC: qc1, Epoch: ep}) require.NoError(t, err, "good range, good index should succeed") } } -// testSignedBlock creates a signed lane proposal for a given lane, block number, and parent hash. func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber, parent types.BlockHeaderHash, rng utils.Rng) *types.Signed[*types.LaneProposal] { block := types.NewBlock(lane, n, parent, types.GenPayload(rng)) return types.Sign(key, types.NewLaneProposal(block)) @@ -70,57 +71,67 @@ func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber func TestNewInnerFreshStart(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) - - i, err := newInner(registry.LatestEpoch(), utils.None[*loadedAvailState]()) - require.NoError(t, err) - - require.False(t, i.latestAppQC.IsPresent()) - require.NotNil(t, i.nextBlockToPersist) - 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) + registry, _ := epoch.GenRegistryAt(rng, 4, 0) + + for _, tc := range []struct { + name string + loaded utils.Option[*loadedAvailState] + }{ + {"none", utils.None[*loadedAvailState]()}, + {"empty_loaded", utils.Some(&loadedAvailState{})}, + } { + t.Run(tc.name, func(t *testing.T) { + i, err := newInner(registry, 0, tc.loaded) + require.NoError(t, err) + + require.False(t, i.app.tip.IsPresent()) + require.NotNil(t, i.lanes.byID) + require.Equal(t, types.RoadIndex(0), i.commits.qcs.first) + require.Equal(t, types.RoadIndex(0), i.commits.qcs.next) + require.Equal(t, registry.FirstBlock(), i.app.votes.first) + require.Equal(t, registry.FirstBlock(), i.app.votes.next) + for lane := range registry.LatestEpoch().Committee().Lanes().All() { + require.Equal(t, types.BlockNumber(0), i.lanes.byID[lane].blocks.first) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[lane].blocks.next) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[lane].votes.first) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[lane].votes.next) + } + }) } } -func TestDecodePruneAnchorIncomplete(t *testing.T) { +func TestDecodeAppTipIncomplete(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)) - _, err := PruneAnchorConv.Decode(&pb.PersistedAvailPruneAnchor{ + _, err := AppTipConv.Decode(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), }) require.Error(t, err) require.Contains(t, err.Error(), "incomplete prune anchor") } -func TestNewInnerLoadedNoAnchor(t *testing.T) { +func TestNewInnerRequiresAnchorWhenEpochNonZero(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) - - loaded := &loadedAvailState{} + registry, _, m := epoch.GenRegistryTip(rng, 4) + require.Equal(t, m, utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(m))).Current.EpochIndex()) - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) - require.NoError(t, err) - - // No anchor loaded, app votes should start at the registry's first block. - require.False(t, i.latestAppQC.IsPresent()) - require.Equal(t, types.RoadIndex(0), i.commitQCs.first) - require.Equal(t, registry.FirstBlock(), i.appVotes.first) + // Prune-leashing seal of 0 means Current > 0 always has an AppQC anchor; + // missing one on restart is a hard fail (do not weaken that contract). + _, err := newInner(registry, epoch.FirstRoad(m), utils.Some(&loadedAvailState{})) + require.Error(t, err) + require.Contains(t, err.Error(), "app tip required") + _, err = newInner(registry, epoch.FirstRoad(m), utils.None[*loadedAvailState]()) + require.Error(t, err) + require.Contains(t, err.Error(), "app tip required") } func TestNewInnerLoadedBlocksContiguous(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // Build 3 contiguous blocks: 0, 1, 2. @@ -136,46 +147,46 @@ 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, 0, utils.Some(loaded)) require.NoError(t, err) - q := i.blocks[lane] + q := i.lanes.byID[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(3), q.next) for j, b := range bs { require.Equal(t, b.Proposal, q.q[types.BlockNumber(j)]) } - // nextBlockToPersist: loaded lane at q.next, other lanes at 0 (map zero-value). - require.NotNil(t, i.nextBlockToPersist) - require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane]) + // persistedBlockNext: loaded lane at q.next, other lanes at 0 (map zero-value). + require.NotNil(t, i.lanes.byID) + require.Equal(t, types.BlockNumber(3), i.lanes.byID[lane].durable.persistedBlockNext) for other := range registry.LatestEpoch().Committee().Lanes().All() { if other != lane { - require.Equal(t, types.BlockNumber(0), i.nextBlockToPersist[other]) + require.Equal(t, types.BlockNumber(0), i.lanes.byID[other].durable.persistedBlockNext) } } } 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, 0, utils.Some(loaded)) require.NoError(t, err) - q := i.blocks[lane] + q := i.lanes.byID[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(0), q.next) } func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) unknownKey := types.GenSecretKey(rng) unknownLane := unknownKey.Public() @@ -185,11 +196,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, 0, utils.Some(loaded)) require.NoError(t, err) for lane := range registry.LatestEpoch().Committee().Lanes().All() { - q := i.blocks[lane] + q := i.lanes.byID[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(0), q.next) } @@ -198,7 +209,7 @@ func TestNewInnerLoadedBlocksUnknownLane(t *testing.T) { func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane0 := keys[0].Public() lane1 := keys[1].Public() @@ -222,31 +233,31 @@ 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, 0, utils.Some(loaded)) require.NoError(t, err) - q0 := i.blocks[lane0] + q0 := i.lanes.byID[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.byID[lane1].blocks require.Equal(t, types.BlockNumber(0), q1.first) require.Equal(t, types.BlockNumber(3), q1.next) - // nextBlockToPersist reflects q.next per loaded lane. - require.Equal(t, types.BlockNumber(2), i.nextBlockToPersist[lane0]) - require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane1]) + // persistedBlockNext reflects q.next per loaded lane. + require.Equal(t, types.BlockNumber(2), i.lanes.byID[lane0].durable.persistedBlockNext) + require.Equal(t, types.BlockNumber(3), i.lanes.byID[lane1].durable.persistedBlockNext) } func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { 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) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -259,25 +270,25 @@ func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // Without anchor, commitQCs.first = 0. All 3 should be restored. - require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(3), inner.commitQCs.next) + require.Equal(t, types.RoadIndex(0), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(3), inner.commits.qcs.next) for i, qc := range qcs { - require.NoError(t, utils.TestDiff(qc, inner.commitQCs.q[types.RoadIndex(i)])) + require.NoError(t, utils.TestDiff(qc, inner.commits.qcs.q[types.RoadIndex(i)])) } - // latestCommitQC should be set to the last loaded one. - latest, ok := inner.latestCommitQC.Load().Get() + // persistedCommitQC should be set to the last loaded one. + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[2], latest)) } 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) @@ -289,7 +300,7 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -301,35 +312,35 @@ func TestNewInnerLoadedCommitQCsWithAppQC(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[2]}), - commitQCs: loadedQCs, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[2]}), + commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - // latestAppQC should be set by prune. - aq, ok := inner.latestAppQC.Get() + // AppQC should be set on the prune anchor. + anchor, ok := inner.app.tip.Get() require.True(t, ok) - require.Equal(t, roadIdx, aq.Proposal().RoadIndex()) + require.Equal(t, roadIdx, anchor.AppQC.Proposal().RoadIndex()) - // inner.prune(appQC@2, commitQC@2) sets commitQCs.first = 2. + // The prune anchor at road 2 sets commitQCs.first = 2. // Indices 2, 3 and 4 remain; earlier ones are pruned. - require.Equal(t, types.RoadIndex(2), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(5), inner.commitQCs.next) - require.NoError(t, utils.TestDiff(qcs[2], inner.commitQCs.q[2])) - require.NoError(t, utils.TestDiff(qcs[3], inner.commitQCs.q[3])) - require.NoError(t, utils.TestDiff(qcs[4], inner.commitQCs.q[4])) - - // latestCommitQC should be the last restored one (index 4). - latest, ok := inner.latestCommitQC.Load().Get() + require.Equal(t, types.RoadIndex(2), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(5), inner.commits.qcs.next) + require.NoError(t, utils.TestDiff(qcs[2], inner.commits.qcs.q[2])) + require.NoError(t, utils.TestDiff(qcs[3], inner.commits.qcs.q[3])) + require.NoError(t, utils.TestDiff(qcs[4], inner.commits.qcs.q[4])) + + // persistedCommitQC should be the last restored one (index 4). + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[4], latest)) } 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. @@ -341,7 +352,7 @@ func TestNewInnerLoadedAllThree(t *testing.T) { qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } // Pre-filtered: only commitQCs >= anchor road index (2). @@ -361,41 +372,41 @@ func TestNewInnerLoadedAllThree(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[2]}), - commitQCs: loadedQCs, - blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[2]}), + commitQCs: loadedQCs, + blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - // AppQC restored. - aq, ok := inner.latestAppQC.Get() + // AppQC restored on the prune anchor. + anchor, ok := inner.app.tip.Get() require.True(t, ok) - require.Equal(t, roadIdx, aq.Proposal().RoadIndex()) + require.Equal(t, roadIdx, anchor.AppQC.Proposal().RoadIndex()) // CommitQCs: prune pushed qcs[2], loading skipped it, added 3 and 4. - require.Equal(t, types.RoadIndex(2), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(5), inner.commitQCs.next) + require.Equal(t, types.RoadIndex(2), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(5), inner.commits.qcs.next) // Blocks loaded. - q := inner.blocks[lane] + q := inner.lanes.byID[lane].blocks require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(3), q.next) - require.Equal(t, types.BlockNumber(3), inner.nextBlockToPersist[lane]) + require.Equal(t, types.BlockNumber(3), inner.lanes.byID[lane].durable.persistedBlockNext) - // latestCommitQC is the last loaded one. - latest, ok := inner.latestCommitQC.Load().Get() + // persistedCommitQC is the last loaded one. + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[4], latest)) } 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, 0, utils.None[*loadedAvailState]()) require.NoError(t, err) // Push blocks 0-4 on one lane. @@ -403,10 +414,10 @@ func TestPruneAdvancesNextBlockToPersist(t *testing.T) { for n := range types.BlockNumber(5) { b := testSignedBlock(keys[0], lane, n, parent, rng) parent = b.Msg().Block().Header().Hash() - i.blocks[lane].pushBack(b) + i.lanes.byID[lane].blocks.pushBack(b) } // Simulate partial persistence: only block 0 persisted. - i.nextBlockToPersist[lane] = 1 + i.lanes.byID[lane].durable.persistedBlockNext = 1 // Build CommitQCs with lane ranges that reference actual blocks. // Each CommitQC covers one block on the lane via a LaneQC. @@ -414,16 +425,16 @@ 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.byID[lane].blocks.q[bn].Msg().Block().Header() laneQCs := map[types.LaneID]*types.LaneQC{ lane: types.NewLaneQC(makeLaneVotes( types.TestKeysWithWeight(registry.LatestEpoch().Committee(), keys, registry.LatestEpoch().Committee().LaneQuorum()), h, )), } - qcs[j] = makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, utils.None[*types.AppQC]()) + qcs[j] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, laneQCs, utils.None[*types.AppQC]()) prev = utils.Some(qcs[j]) - i.commitQCs.pushBack(qcs[j]) + i.commits.qcs.pushBack(qcs[j]) } // Verify QC@2's lane range actually covers blocks (First > 0). @@ -435,23 +446,24 @@ 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]) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + updated, err := i.pushAppTip(&AppTip{AppQC: appQC, CommitQC: qcs[2], Epoch: ep0}) require.NoError(t, err) require.True(t, updated) - // nextBlockToPersist must have advanced to at least the lane's new first + // persistedBlockNext must have advanced to at least the lane's new first // (determined by CommitQC@2's lane range). Without this fix, it would // stay at 1, causing the persist goroutine to busy-loop. - laneFirst := i.blocks[lane].first + laneFirst := i.lanes.byID[lane].blocks.first require.Greater(t, laneFirst, types.BlockNumber(1), "prune should have advanced blocks.first past the old cursor") - require.GreaterOrEqual(t, i.nextBlockToPersist[lane], laneFirst, - "nextBlockToPersist should advance when prune moves blocks.first past it") + require.GreaterOrEqual(t, i.lanes.byID[lane].durable.persistedBlockNext, laneFirst, + "persistedBlockNext should advance when prune moves blocks.first past it") } func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { 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, @@ -459,7 +471,7 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -467,28 +479,28 @@ func TestNewInnerLoadedCommitQCsAllBeforeAppQCArePruned(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[5]}), + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[5]}), } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - // prune() pushes the anchor's CommitQC into the queue. - 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])) + // tipcut insert after prune places the anchor's CommitQC. + require.Equal(t, types.RoadIndex(5), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(6), inner.commits.qcs.next) + require.NoError(t, utils.TestDiff(qcs[5], inner.commits.qcs.q[5])) } func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { 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. qcs := make([]*types.CommitQC, 4) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -496,37 +508,37 @@ func TestNewInnerAnchorWithNoCommitQCFiles(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[3]}), } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - // prune() should push the anchor's CommitQC into the queue. - 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])) + // tipcut insert after prune places the anchor's CommitQC. + require.Equal(t, types.RoadIndex(3), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(4), inner.commits.qcs.next) + require.NoError(t, utils.TestDiff(qcs[3], inner.commits.qcs.q[3])) - // latestAppQC should be set. - aq, ok := inner.latestAppQC.Get() + // AppQC should be set on the prune anchor. + anchor, ok := inner.app.tip.Get() require.True(t, ok) - require.Equal(t, types.RoadIndex(3), aq.Proposal().RoadIndex()) + require.Equal(t, types.RoadIndex(3), anchor.AppQC.Proposal().RoadIndex()) - // persistedBlockStart should be initialized from the anchor's CommitQC. + // persistedBlockFirst should be initialized from the anchor's CommitQC. for lane := range registry.LatestEpoch().Committee().Lanes().All() { expected := qcs[3].LaneRange(lane).First() - require.Equal(t, expected, inner.persistedBlockStart[lane]) + require.Equal(t, expected, inner.lanes.byID[lane].durable.persistedBlockFirst) } } 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]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -542,31 +554,31 @@ func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { commitQCs: loadedQCs, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(registry, 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, 0, utils.Some(loaded)) require.NoError(t, err) - require.Equal(t, types.RoadIndex(0), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(0), inner.commitQCs.next) - _, ok := inner.latestCommitQC.Load().Get() + require.Equal(t, types.RoadIndex(0), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(0), inner.commits.qcs.next) + _, ok := inner.commits.persistedCommitQC.Load().Get() require.False(t, ok) } 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 @@ -574,7 +586,7 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { qcs := make([]*types.CommitQC, 11) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -586,31 +598,31 @@ func TestNewInnerLoadedCommitQCsGapWithAppQCAnchor(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[10]}), - commitQCs: loadedQCs, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[10]}), + commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // Only QC@10 loaded. - require.Equal(t, types.RoadIndex(10), inner.commitQCs.first) - require.Equal(t, types.RoadIndex(11), inner.commitQCs.next) - require.NoError(t, utils.TestDiff(qcs[10], inner.commitQCs.q[10])) + require.Equal(t, types.RoadIndex(10), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(11), inner.commits.qcs.next) + require.NoError(t, utils.TestDiff(qcs[10], inner.commits.qcs.q[10])) - latest, ok := inner.latestCommitQC.Load().Get() + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[10], latest)) // AppQC should be applied via prune. - aq, ok := inner.latestAppQC.Get() + anchor, ok := inner.app.tip.Get() require.True(t, ok) - require.Equal(t, types.RoadIndex(10), aq.Proposal().RoadIndex()) + require.Equal(t, types.RoadIndex(10), anchor.AppQC.Proposal().RoadIndex()) } func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { 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]. @@ -619,7 +631,7 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { qcs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -635,32 +647,32 @@ func TestNewInnerLoadedCommitQCsBelowAnchorSkipped(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[3]}), - commitQCs: loadedQCs, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[3]}), + commitQCs: loadedQCs, } - inner, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + inner, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) - // prune(3) pushes 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() + // tipcut insert places QC@3 (next=4). Indices 1,2,3 are skipped. 4,5 pushed. + require.Equal(t, types.RoadIndex(3), inner.commits.qcs.first) + require.Equal(t, types.RoadIndex(6), inner.commits.qcs.next) + latest, ok := inner.commits.persistedCommitQC.Load().Get() require.True(t, ok) require.NoError(t, utils.TestDiff(qcs[5], latest)) } 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]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -674,18 +686,18 @@ func TestNewInnerLoadedCommitQCsGapAfterAnchorReturnsError(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[2]}), - commitQCs: loadedQCs, + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[2]}), + commitQCs: loadedQCs, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // Blocks 3, 4, 6, 7 with no anchor — queue starts at 0, so block 3 @@ -702,14 +714,14 @@ func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") } func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // Build blocks 0, 1 with correct chaining, then block 2 with wrong parent. @@ -730,14 +742,14 @@ func TestNewInnerLoadedBlocksParentHashMismatchReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "parent hash mismatch") } func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys := epoch.GenRegistryAt(rng, 4, 0) lane := keys[0].Public() // Build BlocksPerLane + 5 contiguous blocks — more than the lane capacity. @@ -756,21 +768,21 @@ func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - _, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + _, err := newInner(registry, 0, utils.Some(loaded)) require.Error(t, err) require.Contains(t, err.Error(), "exceeds capacity") } -func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { +func TestNewInnerAppTipPrunesBlockQueues(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. qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -792,34 +804,34 @@ func TestNewInnerPruneAnchorPrunesBlockQueues(t *testing.T) { } loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: pruneQC}), + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: pruneQC}), commitQCs: []persist.LoadedCommitQC{ {Index: 2, QC: qcs[2]}, }, blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // prune() should advance block queue first to the prune anchor's lane range. 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.byID[l].blocks.first, + "lanes[%v].blocks.first should be advanced by prune to prune anchor lane range", l) } } -func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { +func TestNewInnerAppTipCommitQCUsedForPrune(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. qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -828,18 +840,105 @@ func TestNewInnerPruneAnchorCommitQCUsedForPrune(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) loaded := &loadedAvailState{ - pruneAnchor: utils.Some(&PruneAnchor{AppQC: appQC, CommitQC: qcs[1]}), + appTip: utils.Some(&AppTip{AppQC: appQC, CommitQC: qcs[1]}), commitQCs: []persist.LoadedCommitQC{ {Index: 1, QC: qcs[1]}, {Index: 2, QC: qcs[2]}, }, } - i, err := newInner(registry.LatestEpoch(), utils.Some(loaded)) + i, err := newInner(registry, 0, utils.Some(loaded)) require.NoError(t, err) // prune(appQC@1, pruneQC@1) should advance commitQCs.first to 1. - require.Equal(t, types.RoadIndex(1), i.commitQCs.first) + require.Equal(t, types.RoadIndex(1), i.commits.qcs.first) // CommitQCs 1 and 2 should still be loaded. - require.Equal(t, types.RoadIndex(3), i.commitQCs.next) + require.Equal(t, types.RoadIndex(3), i.commits.qcs.next) +} + +// TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock covers tip-based restart: +// appVotes must be floored by the prune-anchor CommitQC, not registry genesis +// FirstBlock (queue.prune only advances; a too-high bootstrap would stick). +func TestNewInnerAppVotesFloorFromAnchorNotTipFirstBlock(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + 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 + ap := types.NewAppProposal(wantAppFirst, qcs[1].Index(), types.GenAppHash(rng), 0) + loaded := &loadedAvailState{ + appTip: utils.Some(&AppTip{ + 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, loaded.nextCommitQC(), utils.Some(loaded)) + require.NoError(t, err) + require.Equal(t, wantAppFirst, inner.app.votes.first, + "appVotes must follow prune-anchor GlobalRange, not registry.FirstBlock") + require.NotEqual(t, registry.FirstBlock(), inner.app.votes.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, 0, 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.byID, 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.byID[bogusLane] = newLaneState() + + i.advanceEpoch(duo.Current) + require.Contains(t, i.lanes.byID, bogusLane, "old lanes must be retained until lane-expiry") + require.Contains(t, i.lanes.byID, realLane, "active lane removed incorrectly") +} + +func TestAdvanceEpoch_RetainsPrevEpochLanes(t *testing.T) { + rng := utils.TestRng() + // Genesis duo needs no prune anchor; EnsureEpoch(1) gives the next duo. + registry, _ := epoch.GenRegistryAt(rng, 4, 0) + registry.EnsureEpoch(1) + + i, err := newInner(registry, 0, utils.None[*loadedAvailState]()) + require.NoError(t, err) + + // Collect a lane from Current (will become Prev after advance). + var prevLane types.LaneID + for l := range i.epoch.Load().Committee().Lanes().All() { + prevLane = l + break + } + require.Contains(t, i.lanes.byID, prevLane, "Current lane missing before reweight") + + duo1 := utils.OrPanic1(registry.DuoAt(epoch.FirstRoad(1))) + i.advanceEpoch(duo1.Current) + + // Prior Current lane is now in Prev — must be retained for boundary QC collection. + require.Contains(t, i.lanes.byID, prevLane, "Prev-epoch lane deleted prematurely; fullCommitQC needs it") } diff --git a/sei-tendermint/internal/autobahn/avail/lane.go b/sei-tendermint/internal/autobahn/avail/lane.go new file mode 100644 index 0000000000..ce6e570ce4 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/lane.go @@ -0,0 +1,271 @@ +package avail + +import ( + "context" + "fmt" + "log/slog" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// NextBlock returns the index of the next missing block in local storage for the given lane. +func (s *State) NextBlock(lane types.LaneID) types.BlockNumber { + for inner := range s.inner.Lock() { + if ls, ok := inner.lanes.get(lane); ok { + return ls.blocks.next + } + } + return 0 +} + +// Block returns block n of the given lane. +// Waits until the block is available. +// Returns ErrPruned if the block has been already pruned. +func (s *State) Block(ctx context.Context, lane types.LaneID, n types.BlockNumber) (*types.Signed[*types.LaneProposal], error) { + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes.get(lane) + if !ok { + return nil, ErrBadLane + } + q := ls.blocks + if err := ctrl.WaitUntil(ctx, func() bool { return n < q.next }); err != nil { + return nil, err + } + if n < q.first { + return nil, types.ErrPruned + } + return q.q[n], nil + } + panic("unreachable") +} + +// PushBlock pushes a block to the state. +// Waits until all previous blocks are available. +func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LaneProposal]) error { + h := p.Msg().Block().Header() + if p.Key() != h.Lane() { + return fmt.Errorf("signer %v does not match lane %v", p.Key(), h.Lane()) + } + // Snapshot Current once for off-lock verify. Unlike PushVote (which parks + // until Current accepts the signer), we do not wait for future committees — + // lane proposals are not reweighted across epoch advances. + c := s.epoch.Load().Committee() + if err := p.Msg().Verify(c); err != nil { + return fmt.Errorf("block.Verify(): %w", err) + } + if err := p.VerifySig(c); err != nil { + return fmt.Errorf("block.VerifySig(): %w", err) + } + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes.get(h.Lane()) + if !ok { + return ErrBadLane + } + q := ls.blocks + if err := ctrl.WaitUntil(ctx, func() bool { + return h.BlockNumber() <= min(q.next, ls.durable.admitLimit()-1) + }); err != nil { + return err + } + // not needed any more + if q.next != h.BlockNumber() { + return nil + } + // Verify parent hash chain to prevent a malicious producer from + // breaking the block chain, which would deadlock header reconstruction. + // A mismatch means the producer equivocated (produced a different + // chain than we already have). We log it to aid debugging stalled + // lanes but do not return an error — the caller should not tear + // down the peer connection over an equivocating producer. + // NOTE: after pruning (q.first >= q.next), we cannot verify the parent + // hash because the previous block is gone. This is safe because + // headers() never follows the first block's parentHash in a LaneRange. + if q.first < q.next { + prevHash := q.q[q.next-1].Msg().Block().Header().Hash() + if h.ParentHash() != prevHash { + logger.Error("parent hash mismatch (producer equivocation)", + "lane", h.Lane(), + slog.Uint64("block", uint64(h.BlockNumber())), + "got", h.ParentHash(), + "want", prevHash) + return nil + } + } + q.pushBack(p) + ctrl.Updated() + } + return nil +} + +// PushVote parks until Current can accept the vote (signer weight + voted lane), +// verifies, then under lock waits for capacity and credits with the live duo +// (drop if Current advanced and signer left). +// +// Lane-vote streams are committee-only (giga RunServer/RunClient), so parking a +// future-epoch signer does not expose an unauthenticated DoS path. No p2p retry: +// without this wait, async epoch entry would drop the vote permanently. +func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { + h := vote.Msg().Header() + // Future-epoch voters park (one stream goroutine) until Current includes them. + epoch, err := s.epoch.Wait(ctx, func(epoch *types.Epoch) bool { + // TODO: this is not a reliable criterion: fix once we have proper line lifecycle management + c := s.epoch.Load().Committee() + return c.Weight(vote.Key()) > 0 && c.HasLane(h.Lane()) + }) + if err != nil { + return err + } + if err := vote.Msg().Verify(epoch.Committee()); err != nil { + return fmt.Errorf("vote.Verify(): %w", err) + } + if err := vote.VerifySig(epoch.Committee()); err != nil { + return fmt.Errorf("vote.Verify(): %w", err) + } + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes.get(h.Lane()) + if !ok { + return ErrBadLane + } + q := ls.votes + if err := ctrl.WaitUntil(ctx, func() bool { + return h.BlockNumber() < ls.durable.admitLimit() + }); err != nil { + return err + } + // Check if the lane is still live + epoch := inner.epoch.Load() + if epoch.Committee().Weight(vote.Key()) == 0 || !epoch.Committee().HasLane(h.Lane()) { + return nil + } + if h.BlockNumber() < q.first { + return nil + } + for q.next <= h.BlockNumber() { + q.pushBack(newBlockVotes()) + } + if q.q[h.BlockNumber()].pushVote(epoch, vote).IsPresent() { + ctrl.Updated() + } + } + return nil +} + +// headers collects headers for the given range. +func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.BlockHeader, error) { + // Empty range is always available. + if lr.First() == lr.Next() { + return nil, nil + } + want := lr.LastHash() + headers := make([]*types.BlockHeader, lr.Next()-lr.First()) + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes.get(lr.Lane()) + if !ok { + return nil, types.ErrPruned + } + q := ls.votes + for i := range headers { + n := lr.Next() - types.BlockNumber(i) - 1 //nolint:gosec // i is bounded by len(headers) which is a small block range; no overflow risk + for { + // If pruned, then give up. + if q.first > lr.First() { + return nil, types.ErrPruned + } + if bv := q.q[n]; bv != nil { + if set, ok := bv.byHash[want]; ok { + want = set.header.ParentHash() + headers[len(headers)-i-1] = set.header + break + } + } + // Otherwise, wait. + if err := ctrl.Wait(ctx); err != nil { + return nil, err + } + } + } + } + return headers, nil +} + +// WaitForLocalCapacity waits until the lane owned by this node has capacity for toProduce block. +func (s *State) WaitForLocalCapacity(ctx context.Context, toProduce types.BlockNumber) error { + lane := s.key.Public() + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes.get(lane) + if !ok { + return ErrBadLane + } + if err := ctrl.WaitUntil(ctx, func() bool { + return toProduce < ls.durable.admitLimit() + }); err != nil { + return err + } + } + return nil +} + +// WaitForLaneQCs waits until there is at least 1 LaneQC in the Current epoch +// with a block not finalized by prev. Returns the Current epoch alongside the +// QCs so the caller can verify it matches the epoch it intends to propose in. +func (s *State) WaitForLaneQCs( + ctx context.Context, prev utils.Option[*types.CommitQC], +) (map[types.LaneID]*types.LaneQC, *types.Epoch, error) { + for inner, ctrl := range s.inner.Lock() { + laneQCs := map[types.LaneID]*types.LaneQC{} + for { + epoch := inner.epoch.Load() + for lane := range epoch.Committee().Lanes().All() { + first := types.LaneRangeOpt(prev, lane).Next() + for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { + if qc, ok := inner.lanes.laneQC(lane, first+i).Get(); ok { + laneQCs[lane] = qc + } else { + break + } + } + } + if len(laneQCs) > 0 { + return laneQCs, epoch, nil + } + if err := ctrl.Wait(ctx); err != nil { + return nil, nil, err + } + } + } + panic("unreachable") +} + +// ProduceLocalBlock appends a new block to the producers lane. +// Fails in case there is not enough capacity in the lane, or it is not the next block expected. +func (s *State) ProduceLocalBlock(n types.BlockNumber, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { + return s.produceLocalBlock(n, s.key, payload) +} + +// TODO: produceLocalBlock is a separate function for testing - consider improving the tests to use ProduceBlock only. +func (s *State) produceLocalBlock(n types.BlockNumber, key types.SecretKey, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { + lane := key.Public() + var result *types.Signed[*types.LaneProposal] + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes.get(lane) + if !ok { + return nil, ErrBadLane + } + q := ls.blocks + if n >= ls.durable.admitLimit() { + return nil, fmt.Errorf("lane full") + } + if q.next != n { + return nil, fmt.Errorf("unexpected block number: got %v, want %v", n, q.next) + } + var parent types.BlockHeaderHash + if q.first < q.next { + parent = q.q[q.next-1].Msg().Block().Header().Hash() + } + result = types.Sign(key, types.NewLaneProposal(types.NewBlock(lane, q.next, parent, payload))) + q.pushBack(result) + ctrl.Updated() + } + return result, nil +} diff --git a/sei-tendermint/internal/autobahn/avail/lane_collection.go b/sei-tendermint/internal/autobahn/avail/lane_collection.go new file mode 100644 index 0000000000..f5a7e4220b --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/lane_collection.go @@ -0,0 +1,93 @@ +package avail + +import ( + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" +) + +// laneCollection owns per-lane block/vote pipelines and their durable ranges. +type laneCollection struct { + byID map[types.LaneID]*laneState +} + +type laneState struct { + blocks *queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] + votes *queue[types.BlockNumber, *blockVotes] + durable laneDurability +} + +// laneDurability tracks the durable prune floor and write tip for one lane. +// Invariant: persistedBlockFirst ≤ blocks.first ≤ persistedBlockNext ≤ blocks.next +// (blocks.first/next live on laneState; this type owns only the durable cursors). +type laneDurability struct { + // persistedBlockNext tracks how far block persistence has progressed. + // RecvBatch only yields blocks below this cursor for voting. + // Always initialized (even when persistence is disabled — the no-op persist + // goroutine bumps it immediately). Not persisted to disk: on restart it is + // reconstructed from the blocks already on disk (see newInner). + // + // TODO: consider giving this its own AtomicSend to avoid waking unrelated + // inner waiters (PushVote, PushCommitQC, etc.) on markBlockPersisted calls. + // Now that blocks are persisted concurrently by lane (one notification per + // lane per batch, not per block), the frequency is lower, but still not + // ideal. Only RecvBatch needs to be notified of cursor changes; + // collectPersistBatch is in the same goroutine and reads it directly. + persistedBlockNext types.BlockNumber + // persistedBlockFirst is the block number derived from the last durably + // persisted prune anchor for this lane. Block admission (PushBlock, + // ProduceBlock, WaitForCapacity, PushVote) uses persistedBlockFirst + + // BlocksPerLane as the capacity limit, ensuring we never admit more blocks + // than can be recovered after a crash. + persistedBlockFirst types.BlockNumber +} + +func newLaneState() *laneState { + return &laneState{ + blocks: newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]](), + votes: newQueue[types.BlockNumber, *blockVotes](), + } +} + +func (c *laneCollection) get(lane types.LaneID) (*laneState, bool) { + ls, ok := c.byID[lane] + return ls, ok +} + +func (c *laneCollection) getOrInsert(lane types.LaneID) *laneState { + if ls, ok := c.byID[lane]; ok { + return ls + } + ls := newLaneState() + c.byID[lane] = ls + return ls +} + +func (c *laneCollection) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { + bv, ok := c.byID[lane].votes.q[n] + if !ok { + return utils.None[*types.LaneQC]() + } + return bv.laneQC() +} + +func (d *laneDurability) admitLimit() types.BlockNumber { + return d.persistedBlockFirst + BlocksPerLane +} + +func (d *laneDurability) advanceFirst(first types.BlockNumber) bool { + if first > d.persistedBlockFirst { + d.persistedBlockFirst = first + return true + } + return false +} + +func (d *laneDurability) advanceNext(next types.BlockNumber) { + d.persistedBlockNext = next +} + +func (d *laneDurability) floorNext(first types.BlockNumber) { + if d.persistedBlockNext < first { + d.persistedBlockNext = first + } +} diff --git a/sei-tendermint/internal/autobahn/avail/persistence.go b/sei-tendermint/internal/autobahn/avail/persistence.go new file mode 100644 index 0000000000..4667ef9dc8 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/persistence.go @@ -0,0 +1,281 @@ +package avail + +import ( + "context" + "fmt" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" + pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/protoutils" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" +) + +// persisters holds all disk persistence components. Either all are present +// (real I/O) or all are no-op (testing). It is a pure I/O struct — all inner +// state access goes through State methods. +type persisters struct { + pruneAnchor persist.Persister[*pb.PersistedAvailPruneAnchor] + blocks *persist.BlockPersister + commitQCs *persist.CommitQCPersister +} + +// innerFile is the A/B file prefix for avail inner state persistence. +const innerFile = "avail_inner" + +// AppTipConv maps the live App tip to the persisted prune-watermark proto +// (AppQC + CommitQC). Epoch is live-only and recovered on load. +var AppTipConv = protoutils.Conv[*AppTip, *pb.PersistedAvailPruneAnchor]{ + Encode: func(t *AppTip) *pb.PersistedAvailPruneAnchor { + return &pb.PersistedAvailPruneAnchor{ + AppQc: types.AppQCConv.Encode(t.AppQC), + CommitQc: types.CommitQCConv.Encode(t.CommitQC), + } + }, + Decode: func(p *pb.PersistedAvailPruneAnchor) (*AppTip, error) { + if p.AppQc == nil || p.CommitQc == nil { + return nil, fmt.Errorf("incomplete prune anchor: AppQC=%v CommitQC=%v", p.AppQc != nil, p.CommitQc != nil) + } + appQC, err := types.AppQCConv.Decode(p.AppQc) + if err != nil { + return nil, fmt.Errorf("decode AppQC: %w", err) + } + commitQC, err := types.CommitQCConv.Decode(p.CommitQc) + if err != nil { + return nil, fmt.Errorf("decode CommitQC: %w", err) + } + return &AppTip{AppQC: appQC, CommitQC: commitQC}, nil + }, +} + +// loadPersistedState creates persisters for the given directory option and loads +// any existing state from disk. When dir is None, all persisters are no-op +// and no state is loaded. When an App tip watermark is present, stale commitQCs +// and blocks below that tip are filtered out before returning. +func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailState], persisters, error) { + prunePersister, persistedAppTip, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + if err != nil { + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) + } + + bp, blocks, err := persist.NewBlockPersister(dir) + if err != nil { + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewBlockPersister: %w", err) + } + + cp, commitQCs, err := persist.NewCommitQCPersister(dir) + if err != nil { + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewCommitQCPersister: %w", err) + } + + pers := persisters{pruneAnchor: prunePersister, blocks: bp, commitQCs: cp} + + if _, ok := dir.Get(); !ok { + return utils.None[*loadedAvailState](), pers, nil + } + + loaded := &loadedAvailState{commitQCs: commitQCs, blocks: blocks} + + if raw, ok := persistedAppTip.Get(); ok { + tip, err := AppTipConv.Decode(raw) + if err != nil { + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("decode app tip: %w", err) + } + loaded.appTip = utils.Some(tip) + + tipIdx := tip.AppQC.Proposal().RoadIndex() + filtered := commitQCs[:0] + for _, lqc := range commitQCs { + if lqc.Index >= tipIdx { + filtered = append(filtered, lqc) + } + } + loaded.commitQCs = filtered + + for lane, bs := range blocks { + first := tip.CommitQC.LaneRange(lane).First() + j := 0 + for j < len(bs) && bs[j].Number < first { + j++ + } + if j > 0 { + loaded.blocks[lane] = bs[j:] + } + } + } + + return utils.Some(loaded), pers, nil +} + +// runPersist is the main loop for the persist goroutine. +// Write order: +// 1. App tip snapshot (AppQC + CommitQC) — the crash-recovery prune watermark (sequential). +// 2. commitQCs.MaybePruneAndPersist and each lane's blocks.MaybePruneAndPersistLane run +// concurrently via scope.Parallel (separate WALs, no early cancellation; first error +// is returned after all tasks finish). +// Each path publishes (markCommitQCsPersisted / markBlockPersisted) per entry so voting +// unblocks ASAP. +// +// On restart, the persisted App tip establishes the retained boundary. +// +// TODO: use a single WAL for anchor and CommitQCs to make +// this atomic rather than relying on write order. +func (s *State) runPersist(ctx context.Context, pers persisters) error { + var persistedAnchorNext types.RoadIndex + for { + batch, err := s.collectPersistBatch(ctx, persistedAnchorNext) + if err != nil { + return err + } + + // The same tip CommitQC drives commit-QC WAL and per-lane block WAL + // (truncate-then-append below this QC). + var tipQC utils.Option[*types.CommitQC] + // 1. Persist App tip first — establishes the crash-recovery prune watermark. + if tip, ok := batch.appTip.Get(); ok { + if err := pers.pruneAnchor.Persist(AppTipConv.Encode(tip)); err != nil { + return fmt.Errorf("persist app tip: %w", err) + } + s.advancePersistedBlockStart(tip.CommitQC) + persistedAnchorNext = tip.CommitQC.Proposal().Index() + 1 + tipQC = utils.Some(tip.CommitQC) + } + + markBlock := func(p *types.Signed[*types.LaneProposal]) { + header := p.Msg().Block().Header() + s.markBlockPersisted(header.Lane(), header.BlockNumber()+1) + } + + blocksByLane := make(map[types.LaneID][]*types.Signed[*types.LaneProposal]) + for _, proposal := range batch.blocks { + lane := proposal.Msg().Block().Header().Lane() + blocksByLane[lane] = append(blocksByLane[lane], proposal) + } + + // 2. Persist commit-QCs and per-lane blocks in parallel. + // Callees handle empty inputs gracefully (no-op when nothing to write/truncate). + if err := scope.Parallel(func(ps scope.ParallelScope) error { + ps.Spawn(func() error { + return pers.commitQCs.MaybePruneAndPersist(tipQC, batch.commitQCs, utils.Some(func(qc *types.CommitQC) { + s.markCommitQCsPersisted(qc) + })) + }) + // Collect lanes: any lane with blocks in this batch, plus all lanes + // in the tip epoch (for WAL pruning). + // TODO: when epoch transitions land, also union in lanes from all + // epochs that appear in batch.commitQCs so new-epoch lanes are + // never skipped in a cross-epoch batch. + batchLanes := map[types.LaneID]struct{}{} + for lane := range blocksByLane { + batchLanes[lane] = struct{}{} + } + for _, lane := range batch.tipLanes { + batchLanes[lane] = struct{}{} + } + for lane := range batchLanes { + proposals := blocksByLane[lane] + ps.Spawn(func() error { + return pers.blocks.MaybePruneAndPersistLane(lane, tipQC, proposals, utils.Some(markBlock)) + }) + } + return nil + }); err != nil { + return err + } + } +} + +// persistBatch holds the data collected under lock for one persist iteration. +// appTip is a snapshot of app's live tip for the prune watermark — persist +// does not own or look up the tip's CommitQC separately. +type persistBatch struct { + blocks []*types.Signed[*types.LaneProposal] + commitQCs []*types.CommitQC + appTip utils.Option[*AppTip] + tipLanes []types.LaneID +} + +// advancePersistedBlockStart updates the per-lane block admission boundary +// after durably writing the App tip watermark. This unblocks PushBlock/ProduceBlock +// waiters that are gated on persistedBlockFirst + BlocksPerLane. +func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { + for inner, ctrl := range s.inner.Lock() { + for lane, ls := range inner.lanes.byID { + ls.durable.advanceFirst(commitQC.LaneRange(lane).First()) + } + ctrl.Updated() + } +} + +// markBlockPersisted advances the per-lane block persistence cursor. +// Called after each block is persisted so that RecvBatch (and therefore +// voting) can unblock as soon as the block is durable. Safe for concurrent +// callers (acquires s.inner lock internally). +func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { + for inner, ctrl := range s.inner.Lock() { + ls, ok := inner.lanes.get(lane) + if !ok { + return + } + ls.durable.advanceNext(next) + ctrl.Updated() + } +} + +// markCommitQCsPersisted publishes the latest persisted CommitQC, +// gating consensus from advancing until the QC is durable. +func (s *State) markCommitQCsPersisted(qc *types.CommitQC) { + for inner, ctrl := range s.inner.Lock() { + inner.commits.markPersisted(qc) + ctrl.Updated() + } +} + +// collectPersistBatch waits for new blocks or commitQCs and collects them under lock. +func (s *State) collectPersistBatch(ctx context.Context, persistedAnchorNext types.RoadIndex) (persistBatch, error) { + var b persistBatch + for inner, ctrl := range s.inner.Lock() { + // Derive the CommitQC persist cursor from persistedCommitQC. This is + // safe because persistedCommitQC is only advanced by markCommitQCsPersisted + // (after disk write) and on startup (from disk). Advancing the App tip + // does not update persistedCommitQC, so this always reflects persistence + // state. The max clamp with commits.qcs.first handles a tip + // fast-forwarding the queue past the cursor. + commitQCNext := types.NextIndexOpt(inner.commits.persistedCommitQC.Load()) + if err := ctrl.WaitUntil(ctx, func() bool { + if types.NextOpt(inner.app.tip) != persistedAnchorNext { + return true + } + for _, ls := range inner.lanes.byID { + if ls.durable.persistedBlockNext < ls.blocks.next { + return true + } + } + return commitQCNext < inner.commits.qcs.next + }); err != nil { + return b, err + } + for _, ls := range inner.lanes.byID { + start := max(ls.durable.persistedBlockNext, ls.blocks.first) + for n := start; n < ls.blocks.next; n++ { + b.blocks = append(b.blocks, ls.blocks.q[n]) + } + } + commitQCNext = max(commitQCNext, inner.commits.qcs.first) + for n := commitQCNext; n < inner.commits.qcs.next; n++ { + b.commitQCs = append(b.commitQCs, inner.commits.qcs.q[n]) + } + if types.NextOpt(inner.app.tip) != persistedAnchorNext { + if tip, ok := inner.app.tip.Get(); ok { + b.appTip = utils.Some(tip) + // Capture under the same lock as the tip so an epoch slide + // cannot move its committee before I/O. + for lane := range tip.Epoch.Committee().Lanes().All() { + b.tipLanes = append(b.tipLanes, lane) + } + } + } + } + return b, nil +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 40adf5834a..64f6226904 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -4,19 +4,13 @@ import ( "context" "errors" "fmt" - "log/slog" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail/metrics" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" - pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/protoutils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) -// ErrBadLane . var ErrBadLane = errors.New("bad lane") const BlocksPerLane = 3 * types.MaxLaneRangeInProposal @@ -34,6 +28,7 @@ type State struct { key types.SecretKey data *data.State inner utils.Watch[*inner] + epoch utils.AtomicRecv[*types.Epoch] // Load-only view of inner.epoch // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. @@ -44,109 +39,6 @@ func (s *State) PublicKey() types.PublicKey { return s.key.Public() } -// persisters holds all disk persistence components. Either all are present -// (real I/O) or all are no-op (testing). It is a pure I/O struct — all inner -// state access goes through State methods. -type persisters struct { - pruneAnchor persist.Persister[*pb.PersistedAvailPruneAnchor] - blocks *persist.BlockPersister - commitQCs *persist.CommitQCPersister -} - -// innerFile is the A/B file prefix for avail inner state persistence. -const innerFile = "avail_inner" - -// PruneAnchor is the decoded form of the persisted prune anchor -// (AppQC + matching CommitQC pair). It serves as the crash-recovery -// pruning watermark. -type PruneAnchor struct { - AppQC *types.AppQC - CommitQC *types.CommitQC -} - -// PruneAnchorConv converts between PruneAnchor and its protobuf representation. -var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAnchor]{ - Encode: func(a *PruneAnchor) *pb.PersistedAvailPruneAnchor { - return &pb.PersistedAvailPruneAnchor{ - AppQc: types.AppQCConv.Encode(a.AppQC), - CommitQc: types.CommitQCConv.Encode(a.CommitQC), - } - }, - Decode: func(p *pb.PersistedAvailPruneAnchor) (*PruneAnchor, error) { - if p.AppQc == nil || p.CommitQc == nil { - return nil, fmt.Errorf("incomplete prune anchor: AppQC=%v CommitQC=%v", p.AppQc != nil, p.CommitQc != nil) - } - appQC, err := types.AppQCConv.Decode(p.AppQc) - if err != nil { - return nil, fmt.Errorf("decode AppQC: %w", err) - } - commitQC, err := types.CommitQCConv.Decode(p.CommitQc) - if err != nil { - return nil, fmt.Errorf("decode CommitQC: %w", err) - } - return &PruneAnchor{AppQC: appQC, CommitQC: commitQC}, nil - }, -} - -// loadPersistedState creates persisters for the given directory option and loads -// any existing state from disk. When dir is None, all persisters are no-op -// and no state is loaded. When a prune anchor is present, stale commitQCs and -// blocks below the anchor are filtered out before returning. -func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailState], persisters, error) { - prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) - if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) - } - - bp, blocks, err := persist.NewBlockPersister(dir) - if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewBlockPersister: %w", err) - } - - cp, commitQCs, err := persist.NewCommitQCPersister(dir) - if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewCommitQCPersister: %w", err) - } - - pers := persisters{pruneAnchor: prunePersister, blocks: bp, commitQCs: cp} - - if _, ok := dir.Get(); !ok { - return utils.None[*loadedAvailState](), pers, nil - } - - loaded := &loadedAvailState{commitQCs: commitQCs, blocks: blocks} - - if raw, ok := persistedPruneAnchor.Get(); ok { - anchor, err := PruneAnchorConv.Decode(raw) - if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("decode prune anchor: %w", err) - } - loaded.pruneAnchor = utils.Some(anchor) - - anchorIdx := anchor.AppQC.Proposal().RoadIndex() - filtered := commitQCs[:0] - for _, lqc := range commitQCs { - if lqc.Index >= anchorIdx { - filtered = append(filtered, lqc) - } - } - loaded.commitQCs = filtered - - for lane, bs := range blocks { - first := anchor.CommitQC.LaneRange(lane).First() - j := 0 - for j < len(bs) && bs[j].Number < first { - j++ - } - if j > 0 { - loaded.blocks[lane] = bs[j:] - } - } - } - - return utils.Some(loaded), pers, nil -} - // NewState constructs a new availability state. // stateDir is None when persistence is disabled (testing only); a no-op // persist goroutine still runs to bump cursors without disk I/O. @@ -156,480 +48,61 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin return nil, err } - ep := data.Registry().LatestEpoch() - inner, err := newInner(ep, loaded) + // DuoAt(CommitQC tipcut) happens inside newInner. Seeding is + // data.SetupInitialDuo; missing epoch hard-fails. + // Tip order: consensus.NewState requires avail ≥ consensus; avail/consensus + // may lag data and catch up in Run. + commitTip := types.RoadIndex(0) + if ls, ok := loaded.Get(); ok { + commitTip = ls.nextCommitQC() + } + inner, err := newInner(data.Registry(), commitTip, loaded) if err != nil { return nil, err } - // Truncate WAL entries below the prune anchor that were filtered out by - // loadPersistedState. - if ls, ok := loaded.Get(); ok { - if anchor, ok := ls.pruneAnchor.Get(); ok { - for lane := range ep.Committee().Lanes().All() { - if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { - return nil, fmt.Errorf("prune stale block WAL entries: %w", err) - } - } - if err := pers.commitQCs.MaybePruneAndPersist(utils.Some(anchor.CommitQC), nil, utils.None[func(*types.CommitQC)]()); err != nil { - return nil, fmt.Errorf("prune stale commitQC WAL entries: %w", err) - } - } - } - return &State{ key: key, data: data, inner: utils.NewWatch(inner), + epoch: inner.epoch.Subscribe(), persisters: pers, }, nil } func (s *State) FirstCommitQC() types.RoadIndex { for inner := range s.inner.Lock() { - return inner.commitQCs.first + return inner.commits.qcs.first } panic("unreachable") } -// Data returns the data state. -func (s *State) Data() *data.State { - return s.data -} - -// LastCommitQC returns receiver of the LastCommitQC. -func (s *State) LastCommitQC() utils.AtomicRecv[utils.Option[*types.CommitQC]] { +// NextCommitQC is the next CommitQC road after restore/admit (commits.qcs.next). +func (s *State) NextCommitQC() types.RoadIndex { for inner := range s.inner.Lock() { - return inner.latestCommitQC.Subscribe() + return inner.commits.qcs.next } panic("unreachable") } -func (s *State) waitForCommitQC(ctx context.Context, idx types.RoadIndex) error { - _, err := s.LastCommitQC().Wait(ctx, func(qc utils.Option[*types.CommitQC]) bool { - return types.NextIndexOpt(qc) > idx - }) - return err -} - -// LastAppQC returns the latest observed AppQC. -func (s *State) LastAppQC() utils.Option[*types.AppQC] { - for inner := range s.inner.Lock() { - return inner.latestAppQC - } - panic("unreachable") -} - -// WaitForAppQC waits until there is an AppQC for the given index or higher. -// Returns this AppQC and the corresponding CommitQC. -// Together they provide enough information to prune the availability state. -func (s *State) WaitForAppQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, *types.CommitQC, error) { - for inner, ctrl := range s.inner.Lock() { - for { - if appQC, ok := inner.latestAppQC.Get(); ok { - if x := appQC.Proposal().RoadIndex(); x >= idx && inner.commitQCs.next > x { - return appQC, inner.commitQCs.q[x], nil - } - } - if err := ctrl.Wait(ctx); err != nil { - return nil, nil, err - } - } - } - panic("unreachable") -} - -// CommitQC returns the CommitQC for the given index. -func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.CommitQC, error) { - if err := s.waitForCommitQC(ctx, idx); err != nil { - return nil, err - } - for inner := range s.inner.Lock() { - if idx < inner.commitQCs.first { - return nil, types.ErrPruned - } - return inner.commitQCs.q[idx], nil - } - panic("unreachable") -} - -// PushCommitQC pushes a CommitQC to the state. -// Waits until all previous CommitQCs are pushed. -func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { - idx := qc.Proposal().Index() - if idx > 0 { - if err := s.waitForCommitQC(ctx, idx-1); err != nil { - return err - } - } - ep, ok := s.data.Registry().EpochByIndex(qc.Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.Proposal().EpochIndex()) - } - if err := qc.Verify(ep); err != nil { - return fmt.Errorf("qc.Verify(): %w", err) - } - 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()) - } - 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. - ctrl.Updated() - return nil - } - return nil -} - -// 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()) - } - 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) { - return nil - } - // Verify the vote against the CommitQC. - qc := inner.commitQCs.q[idx] - if err := v.Msg().Proposal().Verify(qc); err != nil { - return fmt.Errorf("invalid vote: %w", err) - } - // Push the vote. - n := v.Msg().Proposal().GlobalNumber() - q := inner.appVotes - for q.next <= n { - q.pushBack(newAppVotes()) - } - appQC, ok := q.q[n].pushVote(committee, v) - if !ok { - return nil - } - updated, err := inner.prune(committee, appQC, qc) - if err != nil { - return err - } - if updated { - ctrl.Updated() - } - } - return nil -} - -// 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 { - // 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) - } - 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") - } - for inner, ctrl := range s.inner.Lock() { - updated, err := inner.prune(ep.Committee(), appQC, commitQC) - if err != nil { - return err - } - if updated { - ctrl.Updated() - } - } - return nil +// Data returns the data state. +func (s *State) Data() *data.State { + return s.data } -// 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 { +// LastCommitQC returns receiver of the LastCommitQC. +// The tip is the latest durably persisted CommitQC, not merely the admitted tip. +func (s *State) LastCommitQC() utils.AtomicRecv[utils.Option[*types.CommitQC]] { for inner := range s.inner.Lock() { - if l, ok := inner.blocks[lane]; ok { - return l.next - } - } - return 0 -} - -// Block returns block n of the given lane. -// Waits until the block is available. -// Returns ErrPruned if the block has been already pruned. -func (s *State) Block(ctx context.Context, lane types.LaneID, n types.BlockNumber) (*types.Signed[*types.LaneProposal], error) { - for inner, ctrl := range s.inner.Lock() { - q, ok := inner.blocks[lane] - if !ok { - return nil, ErrBadLane - } - if err := ctrl.WaitUntil(ctx, func() bool { return n < q.next }); err != nil { - return nil, err - } - if n < q.first { - return nil, types.ErrPruned - } - return q.q[n], nil - } - panic("unreachable") -} - -// PushBlock pushes a block to the state. -// Waits until all previous blocks are available. -func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LaneProposal]) error { - h := p.Msg().Block().Header() - if p.Key() != h.Lane() { - return fmt.Errorf("signer %v does not match lane %v", p.Key(), h.Lane()) - } - 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 { - return fmt.Errorf("block.Verify(): %w", err) - } - for inner, ctrl := range s.inner.Lock() { - q, ok := inner.blocks[h.Lane()] - if !ok { - return ErrBadLane - } - if err := ctrl.WaitUntil(ctx, func() bool { - return h.BlockNumber() <= min(q.next, inner.persistedBlockStart[h.Lane()]+BlocksPerLane-1) - }); err != nil { - return err - } - // not needed any more - if q.next != h.BlockNumber() { - return nil - } - // Verify parent hash chain to prevent a malicious producer from - // breaking the block chain, which would deadlock header reconstruction. - // A mismatch means the producer equivocated (produced a different - // chain than we already have). We log it to aid debugging stalled - // lanes but do not return an error — the caller should not tear - // down the peer connection over an equivocating producer. - // NOTE: after pruning (q.first >= q.next), we cannot verify the parent - // hash because the previous block is gone. This is safe because - // headers() never follows the first block's parentHash in a LaneRange. - if q.first < q.next { - prevHash := q.q[q.next-1].Msg().Block().Header().Hash() - if h.ParentHash() != prevHash { - logger.Error("parent hash mismatch (producer equivocation)", - "lane", h.Lane(), - slog.Uint64("block", uint64(h.BlockNumber())), - "got", h.ParentHash(), - "want", prevHash) - return nil - } - } - q.pushBack(p) - ctrl.Updated() - } - return nil -} - -// PushVote pushes a LaneVote to the state. -// Waits until the lane has enough capacity for the new vote. -// It does NOT wait for the previous votes. -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 { - return fmt.Errorf("vote.Verify(): %w", err) - } - h := vote.Msg().Header() - for inner, ctrl := range s.inner.Lock() { - q, ok := inner.votes[h.Lane()] - if !ok { - return ErrBadLane - } - if err := ctrl.WaitUntil(ctx, func() bool { - return h.BlockNumber() < inner.persistedBlockStart[h.Lane()]+BlocksPerLane - }); err != nil { - return err - } - if h.BlockNumber() < q.first { - return nil - } - for q.next <= h.BlockNumber() { - q.pushBack(newBlockVotes()) - } - if _, ok := q.q[h.BlockNumber()].pushVote(inner.epoch, vote); ok { - ctrl.Updated() - } - } - return nil -} - -// headers collects headers for the given range. -func (s *State) headers(ctx context.Context, lr *types.LaneRange) ([]*types.BlockHeader, error) { - // Empty range is always available. - if lr.First() == lr.Next() { - return nil, nil - } - want := lr.LastHash() - headers := make([]*types.BlockHeader, lr.Next()-lr.First()) - for inner, ctrl := range s.inner.Lock() { - q := inner.votes[lr.Lane()] - for i := range headers { - n := lr.Next() - types.BlockNumber(i) - 1 //nolint:gosec // i is bounded by len(headers) which is a small block range; no overflow risk - for { - // If pruned, then give up. - if q.first > lr.First() { - return nil, types.ErrPruned - } - // 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 - break - } - // Otherwise, wait. - if err := ctrl.Wait(ctx); err != nil { - return nil, err - } - } - } - } - return headers, nil -} - -func (s *State) fullCommitQC(ctx context.Context, n types.RoadIndex) (*types.FullCommitQC, error) { - // Collect the CommitQC. - qc, err := s.CommitQC(ctx, n) - if err != nil { - return 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()) - } - for lane := range ep.Committee().Lanes().All() { - headers, err := s.headers(ctx, qc.LaneRange(lane)) - if err != nil { - return nil, err - } - commitHeaders = append(commitHeaders, headers...) - } - return types.NewFullCommitQC(qc, commitHeaders), nil -} - -// WaitForLocalCapacity waits until the lane owned by this node has capacity for toProduce block. -func (s *State) WaitForLocalCapacity(ctx context.Context, toProduce types.BlockNumber) error { - lane := s.key.Public() - for inner, ctrl := range s.inner.Lock() { - if err := ctrl.WaitUntil(ctx, func() bool { - return toProduce < inner.persistedBlockStart[lane]+BlocksPerLane - }); err != nil { - return err - } - } - return nil -} - -// WaitForLaneQCs waits until there is at least 1 LaneQC (for the given epoch) -// with a block not finalized by prev. -func (s *State) WaitForLaneQCs( - ctx context.Context, ep *types.Epoch, prev utils.Option[*types.CommitQC], -) (map[types.LaneID]*types.LaneQC, error) { - for inner, ctrl := range s.inner.Lock() { - laneQCs := map[types.LaneID]*types.LaneQC{} - for { - 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 { - laneQCs[lane] = qc - } else { - break - } - } - } - if len(laneQCs) > 0 { - return laneQCs, nil - } - if err := ctrl.Wait(ctx); err != nil { - return nil, err - } - } + return inner.commits.persistedCommitQC.Subscribe() } panic("unreachable") } -// ProduceLocalBlock appends a new block to the producers lane. -// Fails in case there is not enough capacity in the lane, or it is not the next block expected. -func (s *State) ProduceLocalBlock(n types.BlockNumber, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { - return s.produceLocalBlock(n, s.key, payload) -} - -// TODO: produceLocalBlock is a separate function for testing - consider improving the tests to use ProduceBlock only. -func (s *State) produceLocalBlock(n types.BlockNumber, key types.SecretKey, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { - lane := key.Public() - var result *types.Signed[*types.LaneProposal] - for inner, ctrl := range s.inner.Lock() { - q, ok := inner.blocks[lane] - if !ok { - return nil, ErrBadLane - } - if n >= inner.persistedBlockStart[lane]+BlocksPerLane { - return nil, fmt.Errorf("lane full") - } - if q.next != n { - return nil, fmt.Errorf("unexpected block number: got %v, want %v", n, q.next) - } - var parent types.BlockHeaderHash - if q.first < q.next { - parent = q.q[q.next-1].Msg().Block().Header().Hash() - } - result = types.Sign(key, types.NewLaneProposal(types.NewBlock(lane, q.next, parent, payload))) - q.pushBack(result) - ctrl.Updated() - } - return result, nil -} - // Run runs the background tasks of the state. // // Goroutines: this method spawns long-lived goroutines via scope.SpawnNamed -// (the persist loop and the FullCommitQC→data-state pusher). Inside +// (persist, epoch advance, and the FullCommitQC→data-state pusher). Inside // runPersist, scope.Parallel spawns short-lived goroutines for concurrent // per-lane block and commit-QC persistence. The persist package itself does // not spawn goroutines. @@ -638,7 +111,12 @@ func (s *State) Run(ctx context.Context) error { scope.SpawnNamed("persist", func() error { return s.runPersist(ctx, s.persisters) }) + scope.SpawnNamed("advanceEpoch", func() error { + return s.runAdvanceEpoch(ctx) + }) // Task inserting FullCommitQCs and local blocks to data state. + // ErrPruned jumps n forward (AppQC/window prune during catch-up): skipped + // roads need not be exported locally — peers can PushQC into data. scope.SpawnNamed("s.data.PushQC", func() error { for n := types.RoadIndex(0); ; n = max(n+1, s.FirstCommitQC()) { qc, err := s.fullCommitQC(ctx, n) @@ -649,23 +127,18 @@ 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.get(h.Lane()) + if !ok { + continue + } + if b, ok := ls.blocks.q[h.BlockNumber()]; 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()) } } } @@ -677,178 +150,3 @@ func (s *State) Run(ctx context.Context) error { return nil }) } - -// runPersist is the main loop for the persist goroutine. -// Write order: -// 1. Prune anchor (AppQC + CommitQC pair) — the crash-recovery watermark (sequential). -// 2. commitQCs.MaybePruneAndPersist and each lane's blocks.MaybePruneAndPersistLane run -// concurrently via scope.Parallel (separate WALs, no early cancellation; first error -// is returned after all tasks finish). -// Each path publishes (markCommitQCsPersisted / markBlockPersisted) per entry so voting -// unblocks ASAP. -// -// The prune anchor is a pruning watermark: on restart we resume from it. -// -// TODO: use a single WAL for anchor and CommitQCs to make -// this atomic rather than relying on write order. -func (s *State) runPersist(ctx context.Context, pers persisters) error { - var lastPersistedAppQCNext types.RoadIndex - for { - batch, err := s.collectPersistBatch(ctx, lastPersistedAppQCNext) - if err != nil { - return err - } - - // Prune CommitQC anchor: same Option drives commit-QC WAL and per-lane block WAL - // (truncate-then-append below this QC). - var anchorQC utils.Option[*types.CommitQC] - // 1. Persist prune anchor first — establishes the crash-recovery watermark. - if anchor, ok := batch.pruneAnchor.Get(); ok { - if err := pers.pruneAnchor.Persist(PruneAnchorConv.Encode(anchor)); err != nil { - return fmt.Errorf("persist prune anchor: %w", err) - } - s.advancePersistedBlockStart(anchor.CommitQC) - lastPersistedAppQCNext = anchor.CommitQC.Proposal().Index() + 1 - anchorQC = utils.Some(anchor.CommitQC) - } - - markBlock := func(p *types.Signed[*types.LaneProposal]) { - header := p.Msg().Block().Header() - s.markBlockPersisted(header.Lane(), header.BlockNumber()+1) - } - - blocksByLane := make(map[types.LaneID][]*types.Signed[*types.LaneProposal]) - for _, proposal := range batch.blocks { - lane := proposal.Msg().Block().Header().Lane() - blocksByLane[lane] = append(blocksByLane[lane], proposal) - } - - // 2. Persist commit-QCs and per-lane blocks in parallel. - // Callees handle empty inputs gracefully (no-op when nothing to write/truncate). - if err := scope.Parallel(func(ps scope.ParallelScope) error { - ps.Spawn(func() error { - return pers.commitQCs.MaybePruneAndPersist(anchorQC, batch.commitQCs, utils.Some(func(qc *types.CommitQC) { - s.markCommitQCsPersisted(qc) - })) - }) - // Collect lanes: any lane with blocks in this batch, plus all lanes - // in the anchor epoch (for WAL pruning). - // TODO: when epoch transitions land, also union in lanes from all - // epochs that appear in batch.commitQCs so new-epoch lanes are - // never skipped in a cross-epoch batch. - batchLanes := map[types.LaneID]struct{}{} - for lane := range blocksByLane { - batchLanes[lane] = struct{}{} - } - 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()) - } - for lane := range ep.Committee().Lanes().All() { - batchLanes[lane] = struct{}{} - } - } - for lane := range batchLanes { - proposals := blocksByLane[lane] - ps.Spawn(func() error { - return pers.blocks.MaybePruneAndPersistLane(lane, anchorQC, proposals, utils.Some(markBlock)) - }) - } - return nil - }); err != nil { - return err - } - } -} - -// persistBatch holds the data collected under lock for one persist iteration. -type persistBatch struct { - blocks []*types.Signed[*types.LaneProposal] - commitQCs []*types.CommitQC - pruneAnchor utils.Option[*PruneAnchor] -} - -// advancePersistedBlockStart updates the per-lane block admission watermark -// after durably writing the prune anchor. This unblocks PushBlock/ProduceBlock -// waiters that are gated on persistedBlockStart + BlocksPerLane. -func (s *State) advancePersistedBlockStart(commitQC *types.CommitQC) { - for inner, ctrl := range s.inner.Lock() { - for lane := range inner.blocks { - start := commitQC.LaneRange(lane).First() - if start > inner.persistedBlockStart[lane] { - inner.persistedBlockStart[lane] = start - } - } - ctrl.Updated() - } -} - -// markBlockPersisted advances the per-lane block persistence cursor. -// Called after each block is persisted so that RecvBatch (and therefore -// voting) can unblock as soon as the block is durable. Safe for concurrent -// callers (acquires s.inner lock internally). -func (s *State) markBlockPersisted(lane types.LaneID, next types.BlockNumber) { - for inner, ctrl := range s.inner.Lock() { - inner.nextBlockToPersist[lane] = next - ctrl.Updated() - } -} - -// markCommitQCsPersisted publishes the latest persisted CommitQC, -// gating consensus from advancing until the QC is durable. -func (s *State) markCommitQCsPersisted(qc *types.CommitQC) { - for inner, ctrl := range s.inner.Lock() { - inner.latestCommitQC.Store(utils.Some(qc)) - ctrl.Updated() - } -} - -// collectPersistBatch waits for new blocks or commitQCs and collects them under lock. -func (s *State) collectPersistBatch(ctx context.Context, lastPersistedAppQCNext types.RoadIndex) (persistBatch, error) { - var b persistBatch - for inner, ctrl := range s.inner.Lock() { - // Derive the CommitQC persist cursor from latestCommitQC. This is - // safe because latestCommitQC is only advanced by markCommitQCsPersisted - // (after disk write) and on startup (from disk). prune() does NOT - // update latestCommitQC, so this always reflects persistence state. - // The max clamp with commitQCs.first handles the case where prune() - // fast-forwarded the queue past the cursor. - commitQCNext := types.NextIndexOpt(inner.latestCommitQC.Load()) - if err := ctrl.WaitUntil(ctx, func() bool { - if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { - return true - } - for lane, q := range inner.blocks { - if inner.nextBlockToPersist[lane] < q.next { - return true - } - } - return commitQCNext < inner.commitQCs.next - }); 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]) - } - } - commitQCNext = max(commitQCNext, inner.commitQCs.first) - for n := commitQCNext; n < inner.commitQCs.next; n++ { - b.commitQCs = append(b.commitQCs, inner.commitQCs.q[n]) - } - if types.NextOpt(inner.latestAppQC) != lastPersistedAppQCNext { - if appQC, ok := inner.latestAppQC.Get(); ok { - idx := appQC.Proposal().RoadIndex() - if qc, ok := inner.commitQCs.q[idx]; ok { - b.pruneAnchor = utils.Some(&PruneAnchor{ - AppQC: appQC, - CommitQC: qc, - }) - } - } - } - } - return b, nil -} diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 90d0506df5..ec6d0ff5cd 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "testing" + "testing/synctest" "time" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/memblock" @@ -25,6 +26,46 @@ var ( noCommitQCCB = utils.None[func(*types.CommitQC)]() ) +// registerDuoAtEpoch installs Current=n as the state's operating epoch. +// Seeds Prev|Current in the registry only (no restart N+1 lookahead). +// Does not install an App tip — tests that need App lag (Prev admit) must +// setAppTip, matching production where tip stays in Prev after a sealed advance. +func registerDuoAtEpoch(s *State, n types.EpochIndex) { + r := s.data.Registry() + if n > 0 { + r.EnsureEpoch(n - 1) + } + r.EnsureEpoch(n) + duo := utils.OrPanic1(r.DuoAt(epoch.FirstRoad(n))) + for inner := range s.inner.Lock() { + inner.epoch.Store(duo.Current) + } +} + +// setAppTip installs an App tip for tests that exercise seal leashes +// without going through PushAppQC. +func setAppTip(s *State, appQC *types.AppQC, commitQC *types.CommitQC) { + ep := utils.OrPanic1(s.data.Registry().EpochAt(appQC.Proposal().RoadIndex())) + for inner, ctrl := range s.inner.Lock() { + inner.app.tip = utils.Some(&AppTip{AppQC: appQC, CommitQC: commitQC, Epoch: ep}) + ctrl.Updated() + } +} + +// advanceUntilCurrent runs runAdvanceEpoch until Current reaches want, then cancels. +func advanceUntilCurrent(t *testing.T, s *State, want types.EpochIndex) { + t.Helper() + ctx, cancel := context.WithCancel(t.Context()) + errCh := make(chan error, 1) + go func() { errCh <- s.runAdvanceEpoch(ctx) }() + _, err := s.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= want + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) +} + type byLane[T any] map[types.LaneID][]T func makeAppVotes(keys []types.SecretKey, proposal *types.AppProposal) []*types.Signed[*types.AppVote] { @@ -38,8 +79,8 @@ func makeAppVotes(keys []types.SecretKey, proposal *types.AppProposal) []*types. func TestSubscribeAppVotesJumpsToDataFloor(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc, blocks := data.TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc, blocks := data.TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr := qc.QC().GlobalRange() require.Greater(t, gr.Len(), uint64(2)) @@ -124,7 +165,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { t.Helper() ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) committee := registry.LatestEpoch().Committee() if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { @@ -175,11 +216,11 @@ 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) } - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, state.LastAppQC()) + qc := makeCommitQC(registry.EpochAtTip(prev), keys, prev, laneQCs, state.LastAppQC()) if err := state.PushCommitQC(ctx, qc); err != nil { return fmt.Errorf("state.PushCommitQC(): %w", err) } @@ -249,7 +290,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { // loadPersistedState (stale entries below the prune anchor are discarded). func TestStateRestartFromPersisted(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -296,11 +337,11 @@ 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) } - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, laneQCs, state.LastAppQC()) + qc := makeCommitQC(registry.EpochAtTip(prev), keys, prev, laneQCs, state.LastAppQC()) if err := state.PushCommitQC(ctx, qc); err != nil { return fmt.Errorf("PushCommitQC: %w", err) } @@ -354,7 +395,7 @@ func TestStateRestartFromPersisted(t *testing.T) { func TestStateMismatchedQCs(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() initialBlock := registry.FirstBlock() @@ -364,7 +405,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.NewEpochDuo(types.NewEpoch(0, types.OpenRoadRange(), committee), utils.None[*types.Epoch]())} fullProposal := utils.OrPanic1(types.NewProposal( leaderKey(committee, keys, vs.View()), vs, @@ -403,15 +444,240 @@ 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 TestWaitForAppQC(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + committee := ep0.Committee() + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + canceled, cancel := context.WithCancel(ctx) + cancel() + _, _, err = state.WaitForAppQC(canceled, 0) + require.ErrorIs(t, err, context.Canceled) + + lane := keys[0].Public() + b, err := state.ProduceLocalBlock(state.NextBlock(lane), types.GenPayload(rng)) + 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() { + _, _, err := state.WaitForAppQC(ctx, 0) + done <- err + }() + require.NoError(t, state.PushAppQC(ctx, appQC, qc0)) + require.NoError(t, <-done) + _, _, err = state.WaitForAppQC(ctx, 0) + require.NoError(t, err) + + canceled2, cancel2 := context.WithCancel(ctx) + cancel2() + _, _, err = state.WaitForAppQC(canceled2, 1) + require.ErrorIs(t, err, context.Canceled) +} + +// TestPushVote_WaitsForFutureEpochSigner: a voter not yet in Current parks until +// advanceEpoch installs a committee that includes them, then credits. +func TestPushVote_WaitsForFutureEpochSigner(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + futureKey := types.GenSecretKey(rng) + lane := futureKey.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(futureKey, types.NewLaneVote(header)) + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() // parked: Weight==0 under Current + + weights := map[types.PublicKey]uint64{ + futureKey.Public(): 1000, + keys[1].Public(): 1000, + } + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, + utils.OrPanic1(types.NewCommittee(weights))) + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(ep1) + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + ls, ok := inner.lanes.byID[lane] + require.True(t, ok) + require.Contains(t, ls.votes.q[0].byKey, futureKey.Public()) + } + }) +} + +// TestPushVote_FutureEpochSignerParks: canceled ctx while signer is not in +// Current returns Canceled (park), not a verify error. +func TestPushVote_FutureEpochSignerParks(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + futureKey := types.GenSecretKey(rng) + lane := futureKey.Public() + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(futureKey, types.NewLaneVote(header)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + require.ErrorIs(t, state.PushVote(ctx, vote), context.Canceled) +} + +// TestPushVote_DropsSignerAfterEpochAdvance: after verify, capacity WaitUntil +// releases the lock; advanceEpoch installs a Current that excludes the signer — +// the vote must be dropped (Weight==0). +func TestPushVote_DropsSignerAfterEpochAdvance(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + lane := keys[0].Public() + n := types.BlockNumber(BlocksPerLane) // WaitUntil: n >= persistedBlockFirst+BlocksPerLane + header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(keys[0], types.NewLaneVote(header)) + + weights := map[types.PublicKey]uint64{} + for _, k := range keys[1:] { + weights[k.Public()] = 1000 + } + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, utils.OrPanic1(types.NewCommittee(weights))) + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() // blocked in WaitUntil (capacity) + + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(ep1) + inner.lanes.byID[lane].durable.persistedBlockFirst = 1 // unblock: n < 1+BlocksPerLane + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + require.Equal(t, types.EpochIndex(1), inner.epoch.Load().EpochIndex()) + require.Equal(t, types.BlockNumber(0), inner.lanes.byID[lane].votes.next, + "dropped vote must not extend the queue") + } + }) +} + +// TestPushVote_DropsLaneAfterEpochAdvance: after verify, Current advances to a +// committee that retains the signer but not the voted lane. +func TestPushVote_DropsLaneAfterEpochAdvance(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + lane := keys[1].Public() + n := types.BlockNumber(BlocksPerLane) + header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(keys[0], types.NewLaneVote(header)) + + weights := map[types.PublicKey]uint64{ + keys[0].Public(): 1000, + keys[2].Public(): 1000, + } + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, + utils.OrPanic1(types.NewCommittee(weights))) + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() + + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(ep1) + inner.lanes.byID[lane].durable.persistedBlockFirst = 1 + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + require.Equal(t, types.BlockNumber(0), inner.lanes.byID[lane].votes.next, + "dropped vote must not extend the queue") + } + }) +} + +// TestPushVote_CountsSignerAfterEpochAdvance: same WaitUntil race window, but the +// new Current still includes the signer — count with live committee weights. +func TestPushVote_CountsSignerAfterEpochAdvance(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + ds := newTestDataState(&data.Config{Registry: registry}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + + lane := keys[0].Public() + n := types.BlockNumber(BlocksPerLane) + header := types.NewBlock(lane, n, types.BlockHeaderHash{}, types.GenPayload(rng)).Header() + vote := types.Sign(keys[0], types.NewLaneVote(header)) + + weights := map[types.PublicKey]uint64{keys[0].Public(): 1000, keys[1].Public(): 1000} + ep1 := types.NewEpoch(1, types.RoadRange{First: epoch.FirstRoad(1), Next: epoch.FirstRoad(2)}, utils.OrPanic1(types.NewCommittee(weights))) + + errCh := make(chan error, 1) + go func() { errCh <- state.PushVote(ctx, vote) }() + synctest.Wait() + + for inner, ctrl := range state.inner.Lock() { + inner.advanceEpoch(ep1) + inner.lanes.byID[lane].durable.persistedBlockFirst = 1 + ctrl.Updated() + } + synctest.Wait() + require.NoError(t, <-errCh) + + for inner := range state.inner.Lock() { + ls := inner.lanes.byID[lane] + require.Contains(t, ls.votes.q[n].byKey, keys[0].Public()) + require.Equal(t, uint64(1000), ls.votes.q[n].byHash[header.Hash()].weight) + } + }) +} + func TestPushBlockRejectsBadParentHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) @@ -434,7 +700,7 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { func TestPushBlockRejectsWrongSigner(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) @@ -450,7 +716,8 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { func TestNewStateWithPersistence(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + // Road-0 CommitQC chains: pin genesis epoch so LatestEpoch matches roads. + registry, keys := epoch.GenRegistryAt(rng, 4, 0) initialBlock := registry.FirstBlock() t.Run("empty dir loads fresh state", func(t *testing.T) { @@ -481,7 +748,7 @@ func TestNewStateWithPersistence(t *testing.T) { prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC for i := types.RoadIndex(0); i <= roadIdx; i++ { - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qc := makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qc) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qc}, noCommitQCCB)) pruneQC = qc @@ -547,7 +814,7 @@ func TestNewStateWithPersistence(t *testing.T) { prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC for range roadIdx + 1 { - qc := makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qc := makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qc) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qc}, noCommitQCCB)) pruneQC = qc @@ -595,7 +862,7 @@ func TestNewStateWithPersistence(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -626,7 +893,7 @@ func TestNewStateWithPersistence(t *testing.T) { qcs := make([]*types.CommitQC, 5) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -642,7 +909,7 @@ func TestNewStateWithPersistence(t *testing.T) { state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) - // inner.prune(appQC@1, commitQC@1) sets commitQCs.first = 1. + // The prune anchor at road 1 sets commitQCs.first = 1. require.Equal(t, types.RoadIndex(1), state.FirstCommitQC()) require.NoError(t, utils.TestDiff(utils.Some(qcs[4]), state.LastCommitQC().Load())) }) @@ -654,7 +921,7 @@ func TestNewStateWithPersistence(t *testing.T) { allQCs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range allQCs { - allQCs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + allQCs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(allQCs[i]) } @@ -690,7 +957,7 @@ func TestNewStateWithPersistence(t *testing.T) { qcs := make([]*types.CommitQC, 10) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) } @@ -736,7 +1003,7 @@ func TestNewStateWithPersistence(t *testing.T) { cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for i := range qcs { - qcs[i] = makeCommitQC(registry.LatestEpoch(), keys, prev, nil, utils.None[*types.AppQC]()) + qcs[i] = makeCommitQC(registry.EpochAtTip(prev), keys, prev, nil, utils.None[*types.AppQC]()) prev = utils.Some(qcs[i]) require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } @@ -791,3 +1058,737 @@ func TestNewStateWithPersistence(t *testing.T) { require.Error(t, err) }) } + +func TestWaitForLaneQCs_OnlyReturnsCommitteeLanes(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + + committeeKey := types.GenSecretKey(rng) + committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + committeeKey.Public(): 1, + })) + registry := utils.OrPanic1(epoch.NewRegistry(committee, 0, time.Time{})) + ep := registry.LatestEpoch() + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(committeeKey, ds, utils.None[string]()) + require.NoError(t, err) + + require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + s.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) + s.SpawnBgNamed("avail.Run", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) + + // Produce and vote on a block for the committee lane. + b, err := state.produceLocalBlock(0, committeeKey, types.GenPayload(rng)) + if err != nil { + return err + } + for _, vote := range makeLaneVotes([]types.SecretKey{committeeKey}, b.Msg().Block().Header()) { + if err := state.PushVote(ctx, vote); err != nil { + return err + } + } + + laneQCs, gotEp, err := state.WaitForLaneQCs(ctx, utils.None[*types.CommitQC]()) + if err != nil { + return err + } + if gotEp.EpochIndex() != ep.EpochIndex() { + return fmt.Errorf("WaitForLaneQCs returned epoch %d, want %d", gotEp.EpochIndex(), ep.EpochIndex()) + } + for lane := range laneQCs { + if !ep.Committee().HasLane(lane) { + return fmt.Errorf("WaitForLaneQCs returned lane %v outside committee", lane) + } + } + if _, ok := laneQCs[committeeKey.Public()]; !ok { + return fmt.Errorf("WaitForLaneQCs missing committee lane") + } + return nil + })) +} + +func TestPushAppQCStaleBehindAnchorDrops(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + registerDuoAtEpoch(state, m) + + parentTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + }) + qcPrev := makeCommitQC(epPrev, keys, utils.Some(parentTip), nil, utils.None[*types.AppQC]()) + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) + + qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + + require.NoError(t, state.PushAppQC(t.Context(), appQCM, qcM)) + tip, ok := state.LastAppQC().Get() + require.True(t, ok) + require.Equal(t, qcM.Index(), tip.Proposal().RoadIndex()) + + // Older AppQC behind the anchor tip is a no-op drop. + require.NoError(t, state.PushAppQC(t.Context(), appQCPrev, qcPrev)) + tip2, ok := state.LastAppQC().Get() + require.True(t, ok) + require.Equal(t, tip.Proposal().RoadIndex(), tip2.Proposal().RoadIndex()) +} + +func TestPushAppVoteFutureWaitsForCommitQC(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // AppVote for Current's first road while CommitQC tip is still behind: + // PushAppVote parks in waitForCommitQC (not on the epoch window). + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + proposal := types.NewAppProposal( + registry.FirstBlock(), epM.RoadRange().First, types.GenAppHash(rng), epM.EpochIndex()) + vote := types.Sign(keys[0], types.NewAppVote(proposal)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) +} + +// TestPushAppVoteFarFutureParks: an unregistered far-future RoadIndex parks +// (waitForCommitQC) rather than failing EpochAt up front. +func TestPushAppVoteFarFutureParks(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + far := epoch.FirstRoad(m + 10) + proposal := types.NewAppProposal(0, far, types.GenAppHash(rng), m+10) + vote := types.Sign(keys[0], types.NewAppVote(proposal)) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + require.ErrorIs(t, state.PushAppVote(ctx, vote), context.Canceled) +} + +// TestEpochPastCurrentIsPruned: Epoch(i) when Current has moved past i returns ErrPruned. +func TestEpochPastCurrentIsPruned(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, m) // Current=M + + _, err = state.Epoch(t.Context(), m-1) + require.ErrorIs(t, err, types.ErrPruned, "past Current must be ErrPruned") +} + +func TestPushCommitQCStaleDrops(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + parentTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + }) + qcStale := makeCommitQC(epPrev, keys, utils.Some(parentTip), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(m-1), qcStale.Proposal().Index()) + + state.markCommitQCsPersisted(parentTip) + registerDuoAtEpoch(state, m+1) + + tipBefore := state.LastCommitQC().Load() + require.NoError(t, state.PushCommitQC(t.Context(), qcStale)) + require.Equal(t, tipBefore, state.LastCommitQC().Load(), "stale CommitQC must not advance tip") +} + +// TestFullCommitQCBeforeWindowIsPruned: CommitQC road below commits.qcs.first +// returns ErrPruned so the export loop can jump. +func TestFullCommitQCBeforeWindowIsPruned(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + registerDuoAtEpoch(state, m) + + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + }) + qc := makeCommitQC(epM, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(m), qc.Proposal().Index()) + + // Tip has pruned past the road; export must observe ErrPruned. + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.FirstRoad(m) + 1 + inner.commits.qcs.next = epoch.FirstRoad(m) + 1 + } + state.markCommitQCsPersisted(qc) + + _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(m)) + require.ErrorIs(t, err, types.ErrPruned) +} + +// TestFullCommitQCNeedsAppAnchorEpoch: an admitted CommitQC whose epoch is not +// Current hard-fails without an App-anchor epoch (not a wait, not ErrPruned). +func TestFullCommitQCNeedsAppAnchorEpoch(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + // Current still on Prev; plant a CommitQC for epoch M. + registerDuoAtEpoch(state, m-1) + + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + }) + qc1 := makeCommitQC(epM, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(m), qc1.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.FirstRoad(m) + inner.commits.qcs.next = epoch.FirstRoad(m) + 1 + inner.commits.qcs.q[epoch.FirstRoad(m)] = qc1 + } + state.markCommitQCsPersisted(qc1) + + _, err = state.fullCommitQC(t.Context(), epoch.FirstRoad(m)) + require.Error(t, err) + require.NotErrorIs(t, err, types.ErrPruned) +} + +// TestPushCommitQCMidEpochNoExecLeash: mid-M CommitQC admits without registry +// M+1 (execution may still be in M-1). Only sealing M waits on M+1. +func TestPushCommitQCMidEpochNoExecLeash(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "test setup: M+1 must be absent") + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, m) + + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + }) + qc1 := makeCommitQC(epM, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(m), qc1.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.FirstRoad(m) + inner.commits.qcs.next = epoch.FirstRoad(m) + } + state.markCommitQCsPersisted(prevTip) + + require.NoError(t, state.PushCommitQC(t.Context(), qc1)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.FirstRoad(m)+1, inner.commits.qcs.next) + } +} + +// TestRunAdvanceEpochWaitsForRegistry: seal advance waits on registry M+1 +// (execution leash). PushCommitQC itself admits without that wait. +func TestRunAdvanceEpochWaitsForRegistry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "test setup: M+1 must be absent") + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, m) + + qcMid := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcMid.GlobalRange().First, qcMid.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.LastRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) + setAppTip(state, appQCM, qcMid) + + require.NoError(t, state.PushCommitQC(ctx, qcLast)) + require.Equal(t, m, state.epoch.Load().EpochIndex()) + + errCh := make(chan error, 1) + go func() { errCh <- state.runAdvanceEpoch(ctx) }() + synctest.Wait() // parked on WaitForEpoch(M+1) + require.Equal(t, m, state.epoch.Load().EpochIndex(), "not advanced until exec leash") + + registry.EnsureEpoch(m + 1) + _, err = state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= m+1 + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) + }) +} + +// TestPushAppQCAdmitsWithoutExecLeash: seal PushAppQC admits the tipcut without +// registry M+1; runAdvanceEpoch is what waits on the execution leash. +func TestPushAppQCAdmitsWithoutExecLeash(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + _, err := registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "test setup: M+1 must be absent") + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, m) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.LastRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) + + require.NoError(t, state.PushAppQC(ctx, appQCLast, qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) + } + require.Equal(t, m, state.epoch.Load().EpochIndex()) + + errCh := make(chan error, 1) + go func() { errCh <- state.runAdvanceEpoch(ctx) }() + synctest.Wait() + require.Equal(t, m, state.epoch.Load().EpochIndex(), "advance parked on registry M+1") + + registry.EnsureEpoch(m + 1) + _, err = state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= m+1 + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) + }) +} + +// TestRunAdvanceEpochWaitsForAppAnchor: runAdvanceEpoch does not leave Current +// until the App-anchor epoch covers it (prune leash). PushCommitQC admits freely. +func TestRunAdvanceEpochWaitsForAppAnchor(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) // exec leash for sealing M + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, m) + + qcPrev := makeCommitQC(epPrev, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + })), nil, utils.None[*types.AppQC]()) + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(m), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.LastRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) + setAppTip(state, appQCPrev, qcPrev) // only M-1 — not enough to close M + + require.NoError(t, state.PushCommitQC(ctx, qcLast)) + + errCh := make(chan error, 1) + go func() { errCh <- state.runAdvanceEpoch(ctx) }() + synctest.Wait() + require.Equal(t, m, state.epoch.Load().EpochIndex(), "not advanced without AppQC in M") + + qcM := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcM.GlobalRange().First, qcM.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + setAppTip(state, appQCM, qcM) + + _, err = state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= m+1 + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) + }) +} + +// TestRunAdvanceEpochEpoch0WaitsForAppAnchor: no epoch-0 exemption — advancing +// past 0 waits for an App anchor covering epoch 0. +func TestRunAdvanceEpochEpoch0WaitsForAppAnchor(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + registry, keys := epoch.GenRegistryAt(rng, 4, 0) + registry.EnsureEpoch(1) // exec leash for sealing 0 + ep0 := utils.OrPanic1(registry.EpochAt(0)) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{ + EpochIndex: 0, + Index: epoch.LastRoad(0) - 1, + }))), + }) + qcLast := makeCommitQC(ep0, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.LastRoad(0), qcLast.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.LastRoad(0) + inner.commits.qcs.next = epoch.LastRoad(0) + } + state.markCommitQCsPersisted(prevOnLast) + + require.NoError(t, state.PushCommitQC(ctx, qcLast)) + + errCh := make(chan error, 1) + go func() { errCh <- state.runAdvanceEpoch(ctx) }() + synctest.Wait() + require.Equal(t, types.EpochIndex(0), state.epoch.Load().EpochIndex()) + + qc0 := makeCommitQC(ep0, keys, utils.None[*types.CommitQC](), nil, utils.None[*types.AppQC]()) + appQC0 := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + 0, 0, types.GenAppHash(rng), 0))) + setAppTip(state, appQC0, qc0) + + _, err = state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= 1 + }) + require.NoError(t, err) + cancel() + require.ErrorIs(t, <-errCh, context.Canceled) + }) +} + +// TestPushAppQCBoundaryIncomingAppQC: tipcut closing M may carry the first +// AppQC in M; prune-before-insert makes it visible to runAdvanceEpoch. +func TestPushAppQCBoundaryIncomingAppQC(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, m) + + qcPrev := makeCommitQC(epPrev, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + })), nil, utils.None[*types.AppQC]()) + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcPrev.GlobalRange().First, qcPrev.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + appQCLast := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcLast.GlobalRange().First, qcLast.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.LastRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) + setAppTip(state, appQCPrev, qcPrev) // stale; PushAppQC prune installs appQCLast + + require.NoError(t, state.PushAppQC(t.Context(), appQCLast, qcLast)) + for inner := range state.inner.Lock() { + require.Equal(t, epoch.LastRoad(m)+1, inner.commits.qcs.next) + require.Equal(t, m, inner.epoch.Load().EpochIndex()) + } + advanceUntilCurrent(t, state, m+1) +} + +// TestEpochAdvanceGapHandoff: LastRoad(N) Push leaves Current at N; FirstRoad(N+1) +// parks until runAdvanceEpoch slides the duo. +func TestEpochAdvanceGapHandoff(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + registry.EnsureEpoch(m + 1) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + epNext := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m + 1))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + registerDuoAtEpoch(state, m) + + qcMid := makeCommitQC(epM, keys, utils.Some(types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + })), nil, utils.None[*types.AppQC]()) + appQCM := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + qcMid.GlobalRange().First, qcMid.Index(), types.GenAppHash(rng), epM.EpochIndex()))) + + prevOnLast := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.LastRoad(m) - 1, + }))), + }) + qcLast := makeCommitQC(epM, keys, utils.Some(prevOnLast), nil, utils.None[*types.AppQC]()) + qcNext := makeCommitQC(epNext, keys, utils.Some(qcLast), nil, utils.None[*types.AppQC]()) + require.Equal(t, epoch.FirstRoad(m+1), qcNext.Proposal().Index()) + + for inner := range state.inner.Lock() { + inner.commits.qcs.first = epoch.FirstRoad(m) + inner.commits.qcs.next = epoch.LastRoad(m) + } + state.markCommitQCsPersisted(prevOnLast) + setAppTip(state, appQCM, qcMid) + + require.NoError(t, state.PushCommitQC(t.Context(), qcLast)) + require.Equal(t, m, state.epoch.Load().EpochIndex()) + state.markCommitQCsPersisted(qcLast) // satisfy waitForCommitQC for FirstRoad(m+1) + + advCtx, advCancel := context.WithCancel(t.Context()) + advErr := make(chan error, 1) + go func() { advErr <- state.runAdvanceEpoch(advCtx) }() + + require.NoError(t, state.PushCommitQC(t.Context(), qcNext)) + require.Equal(t, m+1, state.epoch.Load().EpochIndex()) + advCancel() + require.ErrorIs(t, <-advErr, context.Canceled) + }) +} + +func TestPushCommitQCFutureWaitsForCurrent(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 4) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, m-1) + + // Satisfy waitForCommitQC(FirstRoad(m)-1) without pushing EpochLength QCs. + // Current remains M-1, so FirstRoad(m) parks on Epoch(M). + tipQC := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m - 1), + }))), + }) + state.markCommitQCsPersisted(tipQC) + + qcM := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epM, types.View{ + EpochIndex: m, + Index: epoch.FirstRoad(m), + }))), + }) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, state.PushCommitQC(ctx, qcM), context.Canceled) +} + +// TestPushAppQCPreviousEpoch verifies that a late AppQC whose road falls in +// Prev is accepted after Current has advanced. Its committee is resolved from +// the App-tip epoch (Prev), not Current. +func TestPushAppQCPreviousEpoch(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 3) + epPrev := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m - 1))) + + prevTip := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(epPrev, types.View{ + EpochIndex: m - 1, + Index: epoch.LastRoad(m-1) - 1, + }))), + }) + grPrev := prevTip.GlobalRange() + appQCPrev := types.NewAppQC(makeAppVotes(keys, types.NewAppProposal( + grPrev.First, prevTip.Index(), types.GenAppHash(rng), epPrev.EpochIndex()))) + + commitQC := makeCommitQC(epPrev, keys, utils.Some(prevTip), nil, utils.None[*types.AppQC]()) + gr := commitQC.GlobalRange() + appProposal := types.NewAppProposal(gr.First, commitQC.Index(), types.GenAppHash(rng), epPrev.EpochIndex()) + appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) + + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + registerDuoAtEpoch(state, m) + // Sealed advance leaves App tip in Prev while Current is M. + setAppTip(state, appQCPrev, prevTip) + + require.NoError(t, state.PushAppQC(t.Context(), appQC, commitQC), + "late AppQC from Prev should be accepted after Current advanced") + require.True(t, state.LastAppQC().IsPresent()) +} + +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.EpochAtTip(prev), 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{ + appTip: utils.Some(&AppTip{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{ + appTip: utils.Some(&AppTip{AppQC: appQC9, CommitQC: qcs[9]}), + }).nextCommitQC()) + require.Equal(t, types.RoadIndex(10), (&loadedAvailState{ + appTip: utils.Some(&AppTip{AppQC: appQC9, CommitQC: qcs[9]}), + commitQCs: []persist.LoadedCommitQC{ + {Index: 0, QC: qcs[0]}, + {Index: 1, QC: qcs[1]}, + }, + }).nextCommitQC(), "stale WAL below anchor must not win over anchor tipcut") +} diff --git a/sei-tendermint/internal/autobahn/avail/subscriptions.go b/sei-tendermint/internal/autobahn/avail/subscriptions.go index 76c6634409..74f44fa5fe 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions.go @@ -46,10 +46,10 @@ func (r *LaneVotesRecv) RecvBatch(ctx context.Context) ([]*types.Signed[*types.L var batch []*types.BlockHeader for inner, ctrl := range r.state.inner.Lock() { for { - for lane, bq := range inner.blocks { - upperBound := min(bq.next, inner.nextBlockToPersist[lane]) - for i := max(bq.first, r.next[lane]); i < upperBound; i++ { - batch = append(batch, bq.q[i].Msg().Block().Header()) + for lane, ls := range inner.lanes.byID { + upperBound := min(ls.blocks.next, ls.durable.persistedBlockNext) + for i := max(ls.blocks.first, r.next[lane]); i < upperBound; i++ { + batch = append(batch, ls.blocks.q[i].Msg().Block().Header()) } r.next[lane] = upperBound } diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index aee8eead1d..1af8e8da1e 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -61,10 +61,16 @@ func RunTestNetwork(ctx context.Context, states []*State) error { } return err } - next = qc.Index() + 1 if err := to.PushCommitQC(ctx, qc); err != nil { return err } + // PushCommitQC may no-op (stale / not yet Current) and still + // return nil. Only advance once to's tip covers this road so + // we do not skip an index that was never admitted. + if err := to.waitForCommitQC(ctx, qc.Index()); err != nil { + return err + } + next = qc.Index() + 1 } }) } @@ -77,7 +83,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..b0a0e84a24 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -92,20 +92,25 @@ 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. +// Genesis floors for ViewSpec come from State.registry (see State.viewSpec). type inner struct { persistedInner - epoch *types.Epoch + epochs types.EpochDuo } // View returns the current view, embedding the epoch's index. +// Genesis floors are unused here (View only needs CommitQC/TimeoutQC/Epochs). func (i inner) View() types.View { - vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, 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 +122,40 @@ 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 duo = tipcut; CommitQC may be prior epoch. Seeding is data's; + // missing epoch hard-fails. Tip order: NewState requires avail ≥ consensus; + // avail/consensus may lag data and catch up in Run. + nextViewRoad := types.NextIndexOpt(persisted.CommitQC) + duo, err := registry.DuoAt(nextViewRoad) + if err != nil { + return inner{}, fmt.Errorf("DuoAt(%d): %w", nextViewRoad, err) + } + commitEpoch := duo.Current + if cqc, ok := persisted.CommitQC.Get(); ok { + commitEpoch, err = registry.EpochAt(cqc.Proposal().Index()) + if err != nil { + return inner{}, fmt.Errorf("EpochAt(%d): %w", cqc.Proposal().Index(), err) + } + } + if err := persisted.validate(commitEpoch, duo); err != nil { return inner{}, err } logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - return inner{persistedInner: persisted, epoch: ep}, nil + return inner{persistedInner: persisted, 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. Epoch must be seeded; missing → hard error (no WaitForDuo). + ep, err := s.registry.EpochAt(qc.Proposal().Index()) + if err != nil { + return fmt.Errorf("EpochAt(%d): %w", qc.Proposal().Index(), err) + } + if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } for iSend := range s.inner.Lock() { @@ -143,9 +163,19 @@ 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) { + // Tipcut past Current: DuoAt must already be seeded. + duo, err := s.registry.DuoAt(nextRoad) + if err != nil { + logger.Error("tipcut duo not in registry after avail CommitQC tip", + "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 +193,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 +201,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 +244,7 @@ func (s *State) pushPrepareQC(ctx context.Context, qc *types.PrepareQC) error { if vs.View() != qc.Proposal().View() { return nil } - if err := qc.Verify(vs.Epoch); err != nil { + if err := qc.Verify(vs.Epoch()); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } // Update. diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 93d13f84ac..4562337fa5 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -63,7 +63,7 @@ func makePrepareQC(keys []types.SecretKey, proposal *types.Proposal) *types.Prep func TestNewInnerEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 1) + registry, _, _ := epoch.GenRegistry(rng, 1) // No data should return empty inner (persistence disabled / fresh start) i, err := newInner(utils.None[*pb.PersistedInner](), registry) require.NoError(t, err) @@ -72,14 +72,43 @@ func TestNewInnerEmpty(t *testing.T) { require.False(t, i.TimeoutVote.IsPresent(), "timeoutVote should be None") } +// TestNewInner_ConsensusTipLeadsDataWindow: data CommitQC tip is in epoch M; +// registry has M+1 (live AdvanceIfNeeded lookahead). Persisted CommitQC is +// LastRoad(M) → view tipcut FirstRoad(M+1). +func TestNewInner_ConsensusTipLeadsDataWindow(t *testing.T) { + rng := utils.TestRng() + registry, keys, m := epoch.GenRegistryTip(rng, 3) + registry.EnsureEpoch(m + 1) + epM := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(m))) + closing := epoch.LastRoad(m) + tipcut := epoch.FirstRoad(m + 1) + + vote := types.NewCommitVote(types.ProposalAt(epM, types.View{Index: closing})) + votes := make([]*types.Signed[*types.CommitVote], len(keys)) + for i, k := range keys { + votes[i] = types.Sign(k, vote) + } + cqc := types.NewCommitQC(votes) + + i, err := newInner(utils.Some(innerProtoConv.Encode(&persistedInner{ + CommitQC: utils.Some(cqc), + })), registry) + require.NoError(t, err) + require.Equal(t, m+1, i.epochs.Current.EpochIndex()) + require.Equal(t, tipcut, i.View().Index) + prev, ok := i.epochs.Prev.Get() + require.True(t, ok) + require.Equal(t, m, prev.EpochIndex()) +} + func TestNewInnerPrepareVote(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() // Create and persist a prepare vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) vote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, &persistedInner{ @@ -99,9 +128,9 @@ func TestNewInnerCommitVote(t *testing.T) { dir := t.TempDir() // Create and persist a commit vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) vote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -123,7 +152,7 @@ func TestNewInnerTimeoutVote(t *testing.T) { dir := t.TempDir() // Create and persist a timeout vote at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] vote := types.NewFullTimeoutVote(key, types.View{Index: 0, Number: 0}, utils.None[*types.PrepareQC]()) @@ -144,9 +173,9 @@ func TestNewInnerAllVotes(t *testing.T) { dir := t.TempDir() // Create all vote types at genesis view (0, 0) - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) commitVote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -172,9 +201,9 @@ func TestNewInnerPartialState(t *testing.T) { dir := t.TempDir() // Only persist prepareVote - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) key := keys[0] - genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + genesisProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, &persistedInner{ @@ -192,10 +221,10 @@ func TestNewInnerPartialState(t *testing.T) { func TestNewInnerCommitQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create a CommitQC at index 5 - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -221,10 +250,10 @@ func TestNewInnerCommitQC(t *testing.T) { func TestNewInnerTimeoutQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create a CommitQC at index 5 (required for TimeoutQC at index 6) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -255,7 +284,7 @@ func TestNewInnerTimeoutQC(t *testing.T) { func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create TimeoutQC at (0, 2) - no CommitQC needed for index 0 var timeoutVotes []*types.FullTimeoutVote @@ -278,7 +307,7 @@ func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create TimeoutQC at index 6 WITHOUT CommitQC at index 5 var timeoutVotes []*types.FullTimeoutVote @@ -300,10 +329,10 @@ func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -332,10 +361,10 @@ func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 10 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -365,10 +394,10 @@ func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { func TestNewInnerViewSpecValidBothQCs(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -400,10 +429,10 @@ func TestNewInnerViewSpecValidBothQCs(t *testing.T) { func TestNewInnerStaleVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -413,7 +442,7 @@ func TestNewInnerStaleVoteError(t *testing.T) { // Create stale vote at view (3, 0) - before current view (6, 0). // Since inner is persisted atomically, a mismatched view is corrupt. - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) staleVote := types.Sign(keys[0], types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, &persistedInner{ @@ -429,10 +458,10 @@ func TestNewInnerStaleVoteError(t *testing.T) { func TestNewInnerFuturePrepareVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -441,7 +470,7 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future vote at view (10, 0) - ahead of current view (6, 0) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewPrepareVote(futureProposal)) seedPersistedInner(dir, &persistedInner{ @@ -458,10 +487,10 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { func TestNewInnerFutureCommitVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -470,7 +499,7 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future commit vote at view (10, 0) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewCommitVote(futureProposal)) seedPersistedInner(dir, &persistedInner{ @@ -487,10 +516,10 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { func TestNewInnerFutureTimeoutVoteError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -515,10 +544,10 @@ func TestNewInnerFutureTimeoutVoteError(t *testing.T) { func TestNewInnerCurrentViewVoteOk(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -527,7 +556,7 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create vote at exactly current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) currentVote := types.Sign(keys[0], types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, &persistedInner{ @@ -544,14 +573,14 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) // Create CommitQC signed by keys NOT in committee otherKeys := make([]types.SecretKey, 3) for i := range otherKeys { otherKeys[i] = types.GenSecretKey(rng) } - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range otherKeys { @@ -572,10 +601,10 @@ func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -608,10 +637,10 @@ func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -621,7 +650,7 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { // Create vote at current view (6, 0) but signed by key NOT in committee otherKey := types.GenSecretKey(rng) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, &persistedInner{ @@ -638,10 +667,10 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create valid CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -652,7 +681,7 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { // Create stale vote at (3, 0) signed by key NOT in committee. // Since inner is persisted atomically, a mismatched view is corrupt. otherKey := types.GenSecretKey(rng) - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, &persistedInner{ @@ -668,10 +697,10 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { func TestNewInnerPrepareQC(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create prepareQC at genesis view (0, 0) - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC(keys, proposal) seedPersistedInner(dir, &persistedInner{ @@ -687,10 +716,10 @@ func TestNewInnerPrepareQC(t *testing.T) { func TestNewInnerStalePrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -700,7 +729,7 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { // Create stale prepareQC at view (3, 0) - before current view (6, 0). // Since inner is persisted atomically, a mismatched view is corrupt. - staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) + staleProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 3, Number: 0}) stalePrepareQC := makePrepareQC(keys, staleProposal) seedPersistedInner(dir, &persistedInner{ @@ -716,11 +745,11 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Current view is (0, 0) (no CommitQC or TimeoutQC). // CommitVote requires PrepareQC justification. - proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) + proposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0}) commitVote := types.Sign(keys[0], types.NewCommitVote(proposal)) seedPersistedInner(dir, &persistedInner{ @@ -735,10 +764,10 @@ func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { func TestNewInnerFuturePrepareQCError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -747,7 +776,7 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create future prepareQC at index 10 (> current 6) - futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) + futureProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 10, Number: 0}) prepareQC := makePrepareQC(keys, futureProposal) seedPersistedInner(dir, &persistedInner{ @@ -764,10 +793,10 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -776,7 +805,7 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -793,10 +822,10 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -809,7 +838,7 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { for i := range otherKeys { otherKeys[i] = types.GenSecretKey(rng) } - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(otherKeys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -826,11 +855,11 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) voteKey := keys[0] // Create CommitQC at index 5 -> current view is (6, 0) - qcProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 5, Number: 0}) + qcProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -839,7 +868,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, &persistedInner{ @@ -857,7 +886,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { timeoutVote := types.NewFullTimeoutVote(voteKey, currentView, i.PrepareQC) // The timeoutVote should pass verification (which checks prepareQC is correctly included) - err = timeoutVote.Verify(registry.LatestEpoch()) + err = timeoutVote.Verify(utils.OrPanic1(registry.EpochAt(0))) require.NoError(t, err, "timeoutVote with loaded prepareQC should verify") // Verify the loaded prepareQC matches what we persisted @@ -870,62 +899,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 := newTestDataState(registry) + cs, err := newState(&Config{ + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + }, ds, utils.None[persist.Persister[*pb.PersistedInner]](), utils.None[*pb.PersistedInner]()) require.NoError(t, err) - require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") - require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be loaded") - require.True(t, i.CommitVote.IsPresent(), "commitVote should be loaded") - require.True(t, i.TimeoutVote.IsPresent(), "timeoutVote should be loaded") + + // Seed CommitQC so the state is at view (6, 0). + require.NoError(t, cs.pushCommitQC(commitQC)) + + // Inject per-view state directly so we can verify it gets cleared. + for isend := range cs.inner.Lock() { + i := isend.Load() + i.PrepareQC = utils.Some(prepareQC) + i.PrepareVote = utils.Some(prepareVote) + i.CommitVote = utils.Some(commitVote) + i.TimeoutVote = utils.Some(timeoutVote) + isend.Store(i) + } + + i := cs.innerRecv.Load() + require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be present before pushTimeoutQC") require.Equal(t, types.View{Index: 6, Number: 0}, i.View(), "initial view should be (6, 0)") - // Create a TimeoutQC for current view (6, 0) that advances to (6, 1) + // TimeoutQC for (6, 0) advances to (6, 1). var timeoutVotes []*types.FullTimeoutVote for _, k := range keys { timeoutVotes = append(timeoutVotes, types.NewFullTimeoutVote(k, types.View{Index: 6, Number: 0}, utils.Some(prepareQC))) } timeoutQC := types.NewTimeoutQC(timeoutVotes) - // Simulate pushTimeoutQC's Update callback - newInner := inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(timeoutQC)}, epoch: i.epoch} - - // Verify: view advanced to (6, 1) - require.Equal(t, types.View{Index: 6, Number: 1}, newInner.View(), "view should advance to (6, 1)") + ctx := t.Context() + require.NoError(t, cs.pushTimeoutQC(ctx, timeoutQC)) - // Verify: prepareQC and all votes are cleared (they're for old view) - require.False(t, newInner.PrepareQC.IsPresent(), "prepareQC should be cleared") - require.False(t, newInner.PrepareVote.IsPresent(), "prepareVote should be cleared") - require.False(t, newInner.CommitVote.IsPresent(), "commitVote should be cleared") - require.False(t, newInner.TimeoutVote.IsPresent(), "timeoutVote should be cleared") + i = cs.innerRecv.Load() + require.Equal(t, types.View{Index: 6, Number: 1}, i.View(), "view should advance to (6, 1)") + require.False(t, i.PrepareQC.IsPresent(), "prepareQC should be cleared") + require.False(t, i.PrepareVote.IsPresent(), "prepareVote should be cleared") + require.False(t, i.CommitVote.IsPresent(), "commitVote should be cleared") + require.False(t, i.TimeoutVote.IsPresent(), "timeoutVote should be cleared") } // failPersister is a Persister that always returns an error. @@ -938,7 +968,7 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { // and terminates the consensus component (instead of panicking). dir := t.TempDir() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) db := newTestBlockDB(t, filepath.Join(dir, "blockdb")) ds, err := data.NewState(&data.Config{Registry: registry}, db) if err != nil { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index a2231430c7..af470ab2f0 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -4,7 +4,6 @@ import ( "os" "path/filepath" "testing" - "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" @@ -22,7 +21,7 @@ func testCommitQC( laneQCs map[types.LaneID]*types.LaneQC, appQC utils.Option[*types.AppQC], ) *types.CommitQC { - ep := types.NewEpoch(0, types.OpenRoadRange(), time.Time{}, committee, 0) + ep := types.NewEpoch(0, types.OpenRoadRange(), committee) return types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) } @@ -86,7 +85,7 @@ func TestNewCommitQCPersisterEmptyDir(t *testing.T) { func TestPersistCommitQCAndLoad(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -115,7 +114,7 @@ func TestPersistCommitQCAndLoad(t *testing.T) { func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -138,7 +137,7 @@ func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { func TestCommitQCDeleteBeforeZero(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -166,7 +165,7 @@ func TestCommitQCDeleteBeforeZero(t *testing.T) { func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -184,7 +183,7 @@ func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { func TestCommitQCPersistGapRejected(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -203,7 +202,7 @@ func TestCommitQCPersistGapRejected(t *testing.T) { func TestLoadAllDetectsCommitQCGap(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -227,7 +226,7 @@ func TestLoadAllDetectsCommitQCGap(t *testing.T) { func TestNoOpCommitQCPersister(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() qcs := makeSequentialCommitQCs(committee, keys, 11) @@ -261,7 +260,7 @@ func TestNoOpCommitQCPersister(t *testing.T) { func TestCommitQCDeleteBeforePastAll(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -293,7 +292,7 @@ func TestCommitQCDeleteBeforePastAll(t *testing.T) { // must re-establish the cursor so subsequent persists succeed. func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -337,7 +336,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { // re-establishes the cursor for subsequent writes. func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -376,7 +375,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -402,7 +401,7 @@ func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() dir := t.TempDir() @@ -431,7 +430,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/persist.go b/sei-tendermint/internal/autobahn/consensus/persist/persist.go index 4a2dc6ca6e..69a705b156 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/persist.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/persist.go @@ -59,13 +59,11 @@ const ( headerSize = crcSize + seqSize // file header: [4-byte CRC32-C BE][8-byte seq LE] ) -// ErrNoData is returned by loadPersisted when no persisted files exist for the prefix. var ErrNoData = errors.New("no persisted data") -// ErrCorrupt indicates that a persisted file exists but contains invalid data -// (e.g. partially written during a crash). loadPersisted tolerates one corrupt -// file and falls back to the other A/B copy. OS-level errors (permission denied, -// I/O errors) are NOT wrapped with ErrCorrupt and cause loadPersisted to fail. +// ErrCorrupt: persisted file exists but is invalid (e.g. partial crash write). +// loadPersisted tolerates one corrupt A/B copy and falls back to the other; +// OS errors are not wrapped as ErrCorrupt. var ErrCorrupt = errors.New("corrupt persisted data") // dataWithSeq is the unit stored in each A/B file: a sequence number and a proto payload. diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 46e947a672..fc30274a8d 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -71,9 +71,13 @@ 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 { +// +// commitEp verifies the persisted CommitQC. viewDuo is DuoAt(tipcut): Current +// stamps/verifies the open view; at a boundary commitEp and Current differ. +func (p *persistedInner) validate(commitEp *types.Epoch, viewDuo types.EpochDuo) error { + viewEp := viewDuo.Current if cqc, ok := p.CommitQC.Get(); ok { - 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 +90,19 @@ 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: viewDuo, + GenesisFirstBlock: 0, // validate does not use NextGlobalBlock; floors unused here + } 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 +118,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 +135,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..d1b2212089 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] @@ -111,10 +115,12 @@ func newState( } innerSend := utils.Alloc(utils.NewAtomicSend(initialInner)) + registry := data.Registry() s := &State{ cfg: cfg, // metrics: NewMetrics(), avail: availState, + registry: registry, inner: utils.NewMutex(innerSend), innerRecv: innerSend.Subscribe(), persister: pers, @@ -123,16 +129,45 @@ 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, + GenesisFirstBlock: registry.FirstBlock(), + GenesisTimestamp: registry.FirstTimestamp(), + }), myProposal: utils.NewAtomicSend(utils.None[*types.FullProposal]()), myPrepareVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqPrepareVote]()), myCommitVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqCommitVote]()), 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. Consensus may still + // lag data; Run() catch-up from avail closes that gap. + // + // avail < cons is not auto-repaired (see ErrAvailBehindConsensus). + if availTip, consTip := availState.NextCommitQC(), s.CommitTipCut(); availTip < consTip { + return nil, fmt.Errorf("%w: avail tipcut %d < consensus tipcut %d", ErrAvailBehindConsensus, availTip, consTip) + } return s, nil } +// ErrAvailBehindConsensus: avail CommitQC tipcut < consensus tipcut on restart. +// Consensus tip only advances from avail, so this should not occur after a clean +// crash (avail WAL is written before consensus runOutputs persist). +// +// A torn avail CommitQC WAL tail can leave avail one road behind a still-intact +// consensus inner file. There is no clamp: treat it as corrupt local state. +// Recovery: restore both layers from a consistent backup, or wipe +// PersistentStateDir (avail + consensus) and resync — do not delete only one side. +var ErrAvailBehindConsensus = errors.New("avail CommitQC tip behind consensus tip") + +// CommitTipCut is the consensus view tipcut (NextIndexOpt of persisted CommitQC). +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 +204,17 @@ 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 — + // around an epoch boundary peers may disagree for a window; recovery is via + // view timeout (typically one timeout round per transition). + i := s.innerRecv.Load() + if voteEp := vote.Msg().Proposal().View().EpochIndex; voteEp != i.epochs.Current.EpochIndex() { + logger.Debug("dropping prepare vote for non-current epoch", + "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 +226,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 +245,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,8 +269,8 @@ func (s *State) Avail() *avail.State { return s.avail } // Constructs new proposals. func (s *State) runPropose(ctx context.Context) error { return s.myView.Iter(ctx, func(ctx context.Context, vs types.ViewSpec) error { - if vs.Epoch.Committee().Leader(vs.View()) != s.cfg.Key.Public() { - return nil // not the leader. + if !s.shouldPropose(vs) { + return nil } // Try repropose. if fullProposal, ok := types.NewReproposal(s.cfg.Key, vs); ok { @@ -219,11 +278,16 @@ 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) } - // Construct a full proposal. + // Waits can outlive this view (timeout / new tipcut / lost leadership). + if !s.shouldPropose(vs) || ep.EpochIndex() != vs.Epoch().EpochIndex() { + return nil + } + // AppQC is optional on tipcuts: attach only when LastAppQC is newer and + // in-window (buildProposal). Seal AppQC leash is on avail runAdvanceEpoch. fullProposal, err := types.NewProposal( s.cfg.Key, vs, @@ -232,13 +296,23 @@ func (s *State) runPropose(ctx context.Context) error { s.avail.LastAppQC(), ) if err != nil { - return fmt.Errorf("s.avail.WaitForProposal(): %w", err) + return fmt.Errorf("types.NewProposal(): %w", err) } s.myProposal.Store(utils.Some(fullProposal)) return nil }) } +// shouldPropose is true when vs is still the live view and we are its leader. +// Re-check after any avail wait: the view may have advanced while we blocked. +func (s *State) shouldPropose(vs types.ViewSpec) bool { + cur := s.myView.Load() + if cur.View() != vs.View() { + return false + } + return cur.Epoch().Committee().Leader(cur.View()) == s.cfg.Key.Public() +} + func updateOutput[T types.ConsensusReq](w *utils.AtomicSend[utils.Option[T]], v T) { old := w.Load() if !v.View().Less(types.NextViewOpt(old)) { @@ -246,6 +320,18 @@ func updateOutput[T types.ConsensusReq](w *utils.AtomicSend[utils.Option[T]], v } } +// viewSpec builds a ViewSpec for i, taking genesis floors from the registry +// (used only when CommitQC is None). +func (s *State) viewSpec(i inner) types.ViewSpec { + return types.ViewSpec{ + CommitQC: i.CommitQC, + TimeoutQC: i.TimeoutQC, + Epochs: i.epochs, + GenesisFirstBlock: s.registry.FirstBlock(), + GenesisTimestamp: s.registry.FirstTimestamp(), + } +} + // Updates the outputs based on the inner state. // Persists state to disk before broadcasting votes to ensure votes are durable // before dissemination (prevents double-voting on crash). @@ -253,7 +339,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 := s.viewSpec(i) old := s.myView.Load() if old.View().Less(vs.View()) { s.myView.Store(vs) @@ -298,9 +384,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 re-verifies + // against the QC's epoch and aligns the duo without replaying roads. return s.avail.LastCommitQC().Iter(ctx, func(ctx context.Context, last utils.Option[*types.CommitQC]) error { if qc, ok := last.Get(); ok { return s.pushCommitQC(qc) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 0606b4d20d..db83f81404 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -7,8 +7,10 @@ import ( "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -18,7 +20,7 @@ import ( // view timeout (so voteTimeout is only triggered explicitly). // keys[0] is used as the node's signing key. func newTestState(rng utils.Rng) (*State, []types.SecretKey, *epoch.Registry) { - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dataState := newTestDataState(registry) s := utils.OrPanic1(NewState(&Config{ Key: keys[0], @@ -83,7 +85,7 @@ func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) - pqc := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0})) + pqc := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), types.View{Index: 0, Number: 0})) if err := s.pushPrepareQC(ctx, pqc); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -113,7 +115,7 @@ func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { // View (0, 0): push PrepareQC for proposal P. view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -170,7 +172,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // View (0, 0): PrepareQC for P. view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC(pqc0): %w", err) } @@ -183,7 +185,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // Reproposal at (0, 1) succeeds — new PrepareQC at view (0, 1). view1 := types.View{Index: 0, Number: 1} - pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view1)) + pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC(pqc1): %w", err) } @@ -227,7 +229,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { // Fresh PrepareQC at (0, 1). view1 := types.View{Index: 0, Number: 1} - pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view1)) + pqc1 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -256,7 +258,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { // voteTimeout still inherits the PrepareQC from the persisted TimeoutQC. func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() makeCfg := func() *Config { @@ -269,7 +271,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { makeDataState := func() *data.State { return newTestDataState(registry) } view0 := types.View{Index: 0, Number: 0} - pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, registry.LatestEpoch(), view0)) + pqc0 := makePrepareQC(keys, types.GenProposalForEpoch(rng, utils.OrPanic1(registry.EpochAt(0)), view0)) // Session 1: push PrepareQC + TimeoutQC, let runOutputs persist. err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { @@ -319,3 +321,123 @@ 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 := newTestDataState(registry) + ep0 := utils.OrPanic1(registry.EpochAt(0)) + proposal := types.GenProposalForEpoch(rng, ep0, types.View{Index: 0, Number: 0}) + votes := make([]*types.Signed[*types.CommitVote], len(keys)) + 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) +} + +// commitQCAtRoad builds a minimal valid CommitQC at idx under ep (ProposalAt tipcut). +func commitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadIndex) *types.CommitQC { + vote := types.NewCommitVote(types.ProposalAt(ep, types.View{Index: idx})) + votes := make([]*types.Signed[*types.CommitVote], len(keys)) + for i, k := range keys { + votes[i] = types.Sign(k, vote) + } + return types.NewCommitQC(votes) +} + +// fullCommitQCAtRoad wraps commitQCAtRoad with matching headers for BlockDB WriteQC. +func fullCommitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadIndex) *types.FullCommitQC { + lane := ep.Committee().Lanes().At(0) + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() + cqc := commitQCAtRoad(ep, keys, idx) + return types.NewFullCommitQC(cqc, []*types.BlockHeader{header}) +} + +// TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1 is the end-to-end restart +// path for tip interlocking: data CommitQC BlockDB tip stays in epoch N while +// avail/consensus tips are already at FirstRoad(N+1). SetupInitialDuo always +// seeds placeholder N+1/N+2 past the CommitQC window. +func TestRestart_DataTipEpochN_AvailConsensusEpochNPlus1(t *testing.T) { + rng := utils.TestRng() + sks := utils.GenSliceN(rng, 4, types.GenSecretKey) + weights := map[types.PublicKey]uint64{} + for _, sk := range sks { + weights[sk.Public()] = 1000 + } + committee := utils.OrPanic1(types.NewCommittee(weights)) + registry := utils.OrPanic1(epoch.NewRegistry(committee, 1, time.Time{})) + require.NoError(t, registry.SetupInitialDuo(utils.None[types.RoadRange]())) + registry.EnsureEpoch(1) + keys := sks + ep1 := utils.OrPanic1(registry.EpochAt(epoch.FirstRoad(1))) + n := types.EpochIndex(1) + nPlus1 := types.EpochIndex(2) + + dataDir := t.TempDir() + stateDir := t.TempDir() // shared by consensus "inner" + avail "avail_inner" + + // Data tip in epoch N: last loaded CommitQC at FirstRoad(N); tipcut stays in N. + dataRoad := epoch.FirstRoad(n) + dataQC := fullCommitQCAtRoad(ep1, keys, dataRoad) + db1 := newTestBlockDB(t, dataDir) + require.NoError(t, db1.WriteQC(dataQC)) + require.NoError(t, db1.Flush()) + require.NoError(t, db1.Close()) + + closingRoad := epoch.LastRoad(n) // seal of N → tipcut FirstRoad(N+1) + closingQC := commitQCAtRoad(ep1, keys, closingRoad) + gr := closingQC.GlobalRange() + appQC := types.NewAppQC(func() []*types.Signed[*types.AppVote] { + p := types.NewAppProposal(gr.First, closingRoad, types.GenAppHash(rng), ep1.EpochIndex()) + votes := make([]*types.Signed[*types.AppVote], len(keys)) + for i, k := range keys { + votes[i] = types.Sign(k, types.NewAppVote(p)) + } + return votes + }()) + + // Avail tipcut in epoch N+1 via prune-anchor CommitQC at LastRoad(N). + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(stateDir), "avail_inner") + require.NoError(t, err) + require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ + AppQc: types.AppQCConv.Encode(appQC), + CommitQc: types.CommitQCConv.Encode(closingQC), + })) + + // Consensus tipcut in epoch N+1 (view after LastRoad(N)). + seedPersistedInner(stateDir, &persistedInner{CommitQC: utils.Some(closingQC)}) + + leadTip := epoch.FirstRoad(nPlus1) + db2 := newTestBlockDB(t, dataDir) + ds := utils.OrPanic1(data.NewState(&data.Config{ + Registry: registry, + }, db2)) + dataTip, err := ds.CommitTipCut() + require.NoError(t, err) + require.Equal(t, n, epoch.IndexForRoad(dataTip), "data tipcut must stay in epoch N") + _, err = registry.EpochAt(leadTip) + require.NoError(t, err, "executed in data-tip epoch must seed N+1") + + cs, err := NewState(&Config{ + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + PersistentStateDir: utils.Some(stateDir), + }, ds) + require.NoError(t, err) + + consTip := cs.CommitTipCut() + availTip := cs.Avail().NextCommitQC() + require.Equal(t, leadTip, consTip) + require.Equal(t, leadTip, availTip) + require.Equal(t, nPlus1, epoch.IndexForRoad(consTip)) + require.GreaterOrEqual(t, consTip, dataTip, "consensus may lead data") + require.GreaterOrEqual(t, availTip, consTip, "avail may lead consensus") +} diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index f5bbd6ca32..dffb9078d0 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -53,6 +53,9 @@ type inner struct { appProposals map[types.GlobalBlockNumber]*types.AppProposal blockHashes map[types.BlockHeaderHash]types.GlobalBlockNumber + // Store under Watch.Lock; readers use State.epochDuo. + epochDuo utils.AtomicSend[types.EpochDuo] + // first is the exclusive low end of retained in-memory state: maps keep // [first, next*). Set by newInner / skipTo; advanced by evictBelowBound to // min(nextAppProposal, App.GlobalNumber()+1) when a CommitQC.App exists. @@ -85,7 +88,7 @@ func newInner(firstBlock types.GlobalBlockNumber) *inner { } // skipTo advances all cursors to n, discarding everything before it. -// Used on recovery when the first loaded QC starts past committee.FirstBlock() +// Used on recovery when the first loaded QC starts past registry.FirstBlock() // (i.e. data before n was pruned in a previous run). func (i *inner) skipTo(n types.GlobalBlockNumber) { i.first = n @@ -98,12 +101,8 @@ func (i *inner) skipTo(n types.GlobalBlockNumber) { // insertQC verifies and inserts a FullCommitQC into the inner state. // Accepts QCs whose range starts at or before nextQC (partially pruned // prefix is silently skipped). Rejects gaps where gr.First > nextQC. -func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error { - e, ok := registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) - } - if err := qc.Verify(e); err != nil { +func (i *inner) insertQC(qc *types.FullCommitQC, ep *types.Epoch) error { + if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } gr := qc.QC().GlobalRange() @@ -120,7 +119,7 @@ func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error return nil } -// insertBlock inserts a pre-verified block into the inner state. +// insertBlock inserts a block into the inner state. // Requires a QC to already be present for block n. Callers must verify // the block signature before calling (unlike insertQC, which verifies). // @@ -176,10 +175,11 @@ func (i *inner) updateNextBlock(m *metrics.Metrics) { // CommitQC.App appears. nextToExecute uses qc[nextAppProposal] (or the tip QC // when fully caught up), so it does not require retaining nextAppProposal-1. type State struct { - cfg *Config - metrics *metrics.Metrics - inner utils.Watch[*inner] - blockDB types.BlockDB + cfg *Config + metrics *metrics.Metrics + inner utils.Watch[*inner] + epochDuo utils.AtomicRecv[types.EpochDuo] // Load-only view of inner.epochDuo; EpochDuo() reads it + blockDB types.BlockDB } // NewState constructs a data State, replaying persisted state from blockDB. @@ -195,12 +195,78 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { inner: utils.NewWatch(newInner(cfg.Registry.FirstBlock())), blockDB: blockDB, } + // Seed epochs before replay (data owns SetupInitialDuo). + commitQCs, err := commitQCSpan(blockDB) + if err != nil { + return nil, fmt.Errorf("scan CommitQC span: %w", err) + } + if err := cfg.Registry.SetupInitialDuo(commitQCs); err != nil { + return nil, fmt.Errorf("SetupInitialDuo: %w", err) + } + if err := s.loadFromBlockDB(blockDB); err != nil { return nil, fmt.Errorf("loadFromBlockDB: %w", err) } + + // Center epochDuo on CommitQC tipcut (Index+1). + for in := range s.inner.Lock() { + initRoad := types.RoadIndex(0) + if in.nextQC > in.first { + n := in.nextQC - 1 + lastQC := in.qcs[n] + if lastQC == nil { + // Same invariant as CommitTipCut: nextQC claims a retained QC. + return nil, fmt.Errorf("init epochDuo: missing QC at global %d (first=%d nextQC=%d)", n, in.first, in.nextQC) + } + initRoad = lastQC.QC().Proposal().Index() + 1 + } + initDuo, err := cfg.Registry.DuoAt(initRoad) + if err != nil { + return nil, fmt.Errorf("init epochDuo: %w", err) + } + in.epochDuo = utils.NewAtomicSend(initDuo) + s.epochDuo = in.epochDuo.Subscribe() + } return s, nil } +// commitQCSpan returns the half-open [First, Next) of retained CommitQC roads, +// or None when the store holds no QCs. Used to seed SetupInitialDuo. +func commitQCSpan(blockDB types.BlockDB) (utils.Option[types.RoadRange], error) { + it, err := blockDB.Iterator(0) + if err != nil { + return utils.None[types.RoadRange](), fmt.Errorf("open BlockDB iterator: %w", err) + } + defer func() { _ = it.Close() }() + + var first, last types.RoadIndex + var previous *types.FullCommitQC + present := false + for { + pos, ok, err := it.Next() + if err != nil { + return utils.None[types.RoadRange](), fmt.Errorf("advance BlockDB iterator: %w", err) + } + if !ok { + break + } + if pos.QC == previous { + continue + } + previous = pos.QC + idx := pos.QC.QC().Proposal().Index() + if !present { + first = idx + present = true + } + last = idx + } + if !present { + return utils.None[types.RoadRange](), nil + } + return utils.Some(types.RoadRange{First: first, Next: last + 1}), nil +} + // loadFromBlockDB replays QCs and blocks from blockDB into s.inner. // Called from NewState before any goroutines are spawned; the lock is acquired // only to satisfy the Watch API. @@ -219,6 +285,9 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { // Each iterator position has its covering QC and an optional block. Missing // blocks are allowed only at the tail. BlockDB enforces other consistency; this // method only rejects a first QC before committee genesis. +// +// Epochs must already be seeded (SetupInitialDuo) so EpochAt resolves QC committees. +// Live PushQC/PushBlock use epochDuo, not EpochAt. func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { for in := range s.inner.Lock() { err := func() error { @@ -273,7 +342,11 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { // gr.First == n it still fires for the covering QC when the scan opened // inside that QC's range. insertQC clips it to [nextQC, gr.Next). lastQC = qc - if err := in.insertQC(s.cfg.Registry, qc); err != nil { + ep, err := s.cfg.Registry.EpochAt(qc.QC().Proposal().Index()) + if err != nil { + return fmt.Errorf("load QC from BlockDB: epoch lookup: %w", err) + } + if err := in.insertQC(qc, ep); err != nil { return fmt.Errorf("load QC from BlockDB: %w", err) } } @@ -287,9 +360,10 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { return fmt.Errorf("read block %d from BlockDB: %w", n, err) } blk := blkOpt.OrPanic(fmt.Sprintf("block %d absent at a HasBlock position", n)) - e, ok := s.cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) + storedQC := in.qcs[n] + e, err := s.cfg.Registry.EpochAt(storedQC.QC().Proposal().Index()) + if err != nil { + return fmt.Errorf("load block %d from BlockDB: epoch lookup: %w", n, err) } if err := blk.Verify(e.Committee()); err != nil { return fmt.Errorf("verify block %d from BlockDB: %w", n, err) @@ -326,6 +400,28 @@ func (s *State) FirstAppProposal() types.GlobalBlockNumber { panic("unreachable") } +// EpochDuo is a point-in-time snapshot; re-call after a boundary advance. +func (s *State) EpochDuo() types.EpochDuo { return s.epochDuo.Load() } + +// CommitTipCut is the road after the last applied CommitQC (Index+1), or 0 if +// none. +// Returns an error if nextQC claims a retained QC that is missing from the map +// (invariant break) — not the same as an empty tip. +func (s *State) CommitTipCut() (types.RoadIndex, error) { + for inner := range s.inner.Lock() { + if inner.nextQC == 0 || inner.nextQC <= inner.first { + return 0, nil + } + n := inner.nextQC - 1 + qc := inner.qcs[n] + if qc == nil { + return 0, fmt.Errorf("CommitTipCut: missing QC at global %d (first=%d nextQC=%d)", n, inner.first, inner.nextQC) + } + return qc.QC().Proposal().Index() + 1, nil + } + panic("unreachable") +} + // insertBlocksByHash matches byHash against stored (already verified) QC // headers over gr ∩ [nextBlock, nextQC) and inserts hits. Advances nextBlock // when the contiguous prefix grows. Caller must hold inner's lock. @@ -343,15 +439,15 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma return nil } -// PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. -// Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. -// Even if the qc was already pushed earlier, the blocks are pushed anyway. +// PushQC atomically admits qc and optional finalized blocks. +// Tip-order WaitUntil runs before ByRoad so a first QC of the next epoch +// waits for the boundary slide (rather than ErrRoadAfterWindow). Epoch via +// epochDuo only (not Registry). Before-window on a still-needed QC hard-fails; +// if needQC is false the QC is already applied and a before-window miss is a +// no-op. The 4k-block ingest leash is far shorter than the two-epoch window, so +// blocks accompanying such a stale redelivery are already outside the backfill +// horizon; do not soft-admit them via Registry. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { - // 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()) - } gr := qc.QC().GlobalRange() needQC, err := func() (bool, error) { for inner, ctrl := range s.inner.Lock() { @@ -367,12 +463,22 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty if err != nil { return err } + idx := qc.QC().Proposal().Index() + duo := s.epochDuo.Load() + ep, err := duo.ByRoad(idx) + if err != nil { + if !needQC && errors.Is(err, types.ErrPruned) { + return nil + } + return err + } // Verify data. if needQC { if err := qc.Verify(ep); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } } + // Blocks share the QC's epoch (unlike PushBlock, which uses the stored QC). byHash := map[types.BlockHeaderHash]*types.Block{} committee := ep.Committee() for _, b := range blocks { @@ -381,13 +487,28 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty return fmt.Errorf("b.Verify(): %w", err) } } - // Atomically insert QC and blocks. + // Closing Current: WaitForDuo(tipcut) before mutating nextQC. + nextDuo := utils.None[types.EpochDuo]() + if needQC && duo.Current.RoadRange().IsLastRoad(idx) { + nt, err := s.cfg.Registry.WaitForDuo(ctx, idx+1) + if err != nil { + return err + } + nextDuo = utils.Some(nt) + } for inner, ctrl := range s.inner.Lock() { if needQC { + // Only the first inserter may advance epochDuo. + applied := inner.nextQC == gr.First for inner.nextQC < gr.Next { inner.qcs[inner.nextQC] = qc inner.nextQC += 1 } + if applied { + if nd, ok := nextDuo.Get(); ok { + inner.epochDuo.Store(nd) + } + } ctrl.Updated() } if len(byHash) > 0 { @@ -425,8 +546,9 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC // The QC for n must already be present (guaranteed by PushQC ordering), unless // the height is already in the contiguous block prefix (n < nextBlock) — in // that case the block is dropped silently (already stored or executed/evicted). +// Same epochDuo before-window hard-fail as PushQC. func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block *types.Block) error { - var epochIdx types.EpochIndex + var ep *types.Epoch for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { return n < inner.nextQC }); err != nil { return err @@ -437,11 +559,11 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block return nil } // n in [nextBlock, nextQC): QC is contiguous in that range. - epochIdx = inner.qcs[n].QC().Proposal().EpochIndex() - } - ep, ok := s.cfg.Registry.EpochByIndex(epochIdx) - if !ok { - return fmt.Errorf("unknown epoch_index %d", epochIdx) + var err error + ep, err = s.epochDuo.Load().ByRoad(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 { @@ -552,11 +674,13 @@ func (s *State) NeedBlock(n types.GlobalBlockNumber) bool { func assembleGlobalBlock(n types.GlobalBlockNumber, b *types.Block, fqc *types.FullCommitQC) *types.GlobalBlock { qc := fqc.QC() return &types.GlobalBlock{ - GlobalNumber: n, - Timestamp: qc.Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), - Header: b.Header(), - Payload: b.Payload(), - FinalAppState: qc.Proposal().App(), + GlobalNumber: n, + Timestamp: qc.Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), + Header: b.Header(), + Payload: b.Payload(), + FinalAppState: qc.Proposal().App(), + RoadIndex: qc.Proposal().Index(), + LastInCommitQC: qc.GlobalRange().IsLastBlock(n), } } diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index 6e7ebe9e31..bf263fb2dd 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -27,7 +27,7 @@ func (db *recoveryStartBlockDB) Iterator(n types.GlobalBlockNumber) (types.Block // TestRecoveryEmpty verifies that NewState is a no-op on a fresh BlockDB. func TestRecoveryEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() fb := registry.FirstBlock() @@ -45,8 +45,8 @@ func TestRecoveryEmpty(t *testing.T) { func TestNewStateInMemoryMode(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) state := utils.OrPanic1(NewState(&Config{Registry: registry}, memblock.NewBlockDB())) @@ -70,11 +70,11 @@ func TestNewStateInMemoryMode(t *testing.T) { // from BlockDB on restart. func TestRecoveryNormal(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -110,9 +110,9 @@ func TestRecoveryNormal(t *testing.T) { func TestRecoveryStartsAtLastExecutedBlock(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() require.Greater(t, gr2.Len(), 2) @@ -144,9 +144,9 @@ func TestRecoveryStartsAtLastExecutedBlock(t *testing.T) { func TestRecoveryCapsAppTipAtLastBlockInBlockDB(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -175,8 +175,8 @@ func TestRecoveryCapsAppTipAtLastBlockInBlockDB(t *testing.T) { func TestRecoveryRejectsAppTipBeyondCrashWindow(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) db := newTestBlockDB(t, t.TempDir()) writeToBlockDB(t, db, []*types.FullCommitQC{qc}, [][]*types.Block{blocks}) @@ -192,8 +192,8 @@ func TestRecoveryRejectsAppTipBeyondCrashWindow(t *testing.T) { func TestRecoveryStartsAtRegistryFloorWhenBlockDBMissingFirstCommittedBlock(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc, _ := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr := qc.QC().GlobalRange() db := &recoveryStartBlockDB{BlockDB: newTestBlockDB(t, t.TempDir())} @@ -211,7 +211,7 @@ func TestRecoveryStartsAtRegistryFloorWhenBlockDBMissingFirstCommittedBlock(t *t func TestRecoveryRejectsEmptyBlockDBAfterFirstCommittedBlock(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 3) + registry, _, _ := epoch.GenRegistry(rng, 3) lastExecuted := registry.FirstBlock() + 1 _, err := NewState(&Config{ @@ -223,9 +223,9 @@ func TestRecoveryRejectsEmptyBlockDBAfterFirstCommittedBlock(t *testing.T) { func TestRecoveryLeavesAppTipBelowPruneFloorUnreadable(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) db := newTestBlockDB(t, t.TempDir()) writeToBlockDB(t, db, @@ -248,11 +248,11 @@ func TestRecoveryLeavesAppTipBelowPruneFloorUnreadable(t *testing.T) { func TestPruningDiscards(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - qc3, blocks3 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc2.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) + qc3, blocks3 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc2.QC())), keys, utils.Some(qc2.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() gr3 := qc3.QC().GlobalRange() @@ -283,12 +283,12 @@ func TestPruningDiscards(t *testing.T) { // BlockDB only contains data from a later QC range (as left by pruning + GC). func TestRecoveryAfterPruning(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - qc3, blocks3 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc2.QC())) + qc1, _ := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) + qc3, blocks3 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc2.QC())), keys, utils.Some(qc2.QC())) gr2 := qc2.QC().GlobalRange() gr3 := qc3.QC().GlobalRange() @@ -330,11 +330,11 @@ func TestRecoveryAfterPruning(t *testing.T) { func TestRecoveryBlocksBehind(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -378,10 +378,10 @@ func TestRecoveryBlocksBehind(t *testing.T) { // leave the blockless prefix inside [first, nextBlock), which inner's density invariant forbids. func TestRecoveryPartialQCPrefix(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() if gr1.Next-gr1.First < 3 { t.Skip("need at least 3 blocks in QC range to test split") @@ -430,11 +430,11 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { // floor set by the QC pass, so the "block predates first QC start" guard never fires. func TestRecoveryAfterPruneNoGC(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -474,10 +474,10 @@ func TestRecoveryAfterPruneNoGC(t *testing.T) { // cursor sits at the QC start with nextBlock at that floor and no block data. func TestRecoveryQCsNoBlocks(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, _ := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() db1 := newTestBlockDB(t, dir) @@ -506,11 +506,11 @@ func TestRecoveryQCsNoBlocks(t *testing.T) { func TestRunPersistSeedsFromRecoveryFloor(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, _ := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() require.Greater(t, gr2.First, registry.FirstBlock(), "need skipTo past genesis") @@ -558,10 +558,10 @@ func TestRunPersistSeedsFromRecoveryFloor(t *testing.T) { // propagates.) func TestRecoveryBlockGap(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() // TestCommitQC generates 10 global blocks, so the range is always wide @@ -593,3 +593,20 @@ func TestRecoveryBlockGap(t *testing.T) { require.NoError(t, err) require.Equal(t, mid, state.NextBlock(), "replay must resume at the first unfilled number") } + +// TestNewState_AppLeadsBlockDBFlush: app Commit can lead BlockDB flush; NewState +// still boots from the CommitQC span + placeholder N+1/N+2 (no LastExecuted lookup). +func TestNewState_AppLeadsBlockDBFlush(t *testing.T) { + rng := utils.TestRng() + registry, keys, _ := epoch.GenRegistry(rng, 3) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + gr1 := qc1.QC().GlobalRange() + + db := memblock.NewBlockDB() + writeToBlockDB(t, db, []*types.FullCommitQC{qc1}, [][]*types.Block{blocks1}) + tips := db.Status() + require.Equal(t, gr1.Next, tips.NextBlock, "NextBlock is first height of the next QC") + + _, err := NewState(&Config{Registry: registry}, db) + require.NoError(t, err) +} diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 23ae335fb7..08083c2128 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -104,7 +104,7 @@ func pushAppHashesRunning(ctx context.Context, state *State, rng utils.Rng, firs func TestState(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) s.SpawnBgNamed("state.Run()", func() error { @@ -115,7 +115,7 @@ func TestState(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, prev) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(prev), keys, prev) prev = utils.Some(qc.QC()) if err := state.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("state.PushQC(): %w", err) @@ -147,11 +147,13 @@ func TestState(t *testing.T) { } wantG := &types.GlobalBlock{ - GlobalNumber: n, - Timestamp: want.QCs[n].QC().Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), - Header: wantB.Header(), - Payload: wantB.Payload(), - FinalAppState: want.QCs[n].QC().Proposal().App(), + GlobalNumber: n, + Timestamp: want.QCs[n].QC().Proposal().BlockTimestamp(n).OrPanic("global block not in QC"), + Header: wantB.Header(), + Payload: wantB.Payload(), + FinalAppState: want.QCs[n].QC().Proposal().App(), + RoadIndex: want.QCs[n].QC().Proposal().Index(), + LastInCommitQC: want.QCs[n].QC().GlobalRange().IsLastBlock(n), } gotG, err := state.GlobalBlock(ctx, n) if err != nil { @@ -176,12 +178,12 @@ func TestState(t *testing.T) { func TestPushConflictingBadCommitQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) committee := registry.LatestEpoch().Committee() state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push a valid QC to advance inner.nextQC. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, blocks1)) gr1 := qc1.QC().GlobalRange() @@ -221,7 +223,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.NewEpochDuo(registry.EpochAtTip(utils.None[*types.CommitQC]()), utils.None[*types.Epoch]())} leader := committee.Leader(viewSpec.View()) var leaderKey types.SecretKey for _, k := range keys { @@ -271,7 +273,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { } // Verify state is still functional: the next valid QC is accepted and visible. - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) require.NoError(t, state.PushQC(ctx, qc2, blocks2)) gr2 := qc2.QC().GlobalRange() for n := gr2.First; n < gr2.Next; n++ { @@ -284,11 +286,11 @@ func TestPushConflictingBadCommitQC(t *testing.T) { func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push qc1 with NO blocks — only the QC is stored. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, nil)) gr := qc1.QC().GlobalRange() @@ -327,7 +329,7 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { func TestExecution(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) s.SpawnBgNamed("state.Run()", func() error { @@ -337,7 +339,7 @@ func TestExecution(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, prev) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(prev), keys, prev) if err := state.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("state.PushQC(): %w", err) } @@ -369,12 +371,12 @@ func TestExecution(t *testing.T) { func TestPushBlockAcceptsBlockWithQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push QC without blocks. - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, nil)) gr := qc.QC().GlobalRange() @@ -388,11 +390,11 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { func TestGlobalBlockByHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, blocks)) gr := qc.QC().GlobalRange() n := gr.First @@ -429,10 +431,10 @@ func TestGlobalBlockByHash(t *testing.T) { func TestPushQCBeforeRunPersistsToBlockDB(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() db := newTestBlockDB(t, dir) @@ -481,13 +483,13 @@ func TestPushQCBeforeRunPersistsToBlockDB(t *testing.T) { func TestEvictionWaitsForCommitQCApp(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() require.False(t, qc1.QC().Proposal().App().IsPresent(), "genesis CommitQC has no App") - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) app, ok := qc2.QC().Proposal().App().Get() require.True(t, ok, "second CommitQC embeds App for qc1 tip") appFloor := app.GlobalNumber() @@ -551,11 +553,11 @@ func TestEvictionWaitsForCommitQCApp(t *testing.T) { func TestNextToExecuteAfterAppEviction(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) app, ok := qc2.QC().Proposal().App().Get() require.True(t, ok) require.Equal(t, gr1.Next-1, app.GlobalNumber()) @@ -615,9 +617,9 @@ func TestNextToExecuteAfterAppEviction(t *testing.T) { func TestPruningKeepsLastQCRange(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() state1 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -674,10 +676,10 @@ func TestPruningKeepsLastQCRange(t *testing.T) { func TestPruningWithPartialQCRange(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() app, ok := qc2.QC().Proposal().App().Get() @@ -756,16 +758,16 @@ func TestPushBlockWaitsForQC(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push first QC covering [0, N). - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, blocks1)) // Prepare second QC covering [N, M) but don't push it yet. - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() // Block gr2.First should not be in state yet. @@ -806,10 +808,10 @@ func TestPushBlockWaitsForQC(t *testing.T) { func TestTryBlockHidesGapFills(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) + registry, keys, _ := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.EpochAtTip(utils.None[*types.CommitQC]()), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc1.QC())), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() require.GreaterOrEqual(t, gr2.Len(), 2) @@ -847,3 +849,18 @@ func TestTryBlockHidesGapFills(t *testing.T) { require.NoError(t, err) require.Equal(t, blocks2[last-gr2.First], got) } + +// TestCommitTipCutRejectsMissingTipQC: nextQC > first with a nil tip slot is an +// invariant break (same check NewState uses when centering epochDuo). +func TestCommitTipCutRejectsMissingTipQC(t *testing.T) { + rng := utils.TestRng() + registry, _, _ := epoch.GenRegistry(rng, 3) + state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) + for inner := range state.inner.Lock() { + inner.nextQC = inner.first + 1 + delete(inner.qcs, inner.nextQC-1) + } + _, err := state.CommitTipCut() + require.Error(t, err) + require.Contains(t, err.Error(), "missing QC") +} diff --git a/sei-tendermint/internal/autobahn/data/testonly.go b/sei-tendermint/internal/autobahn/data/testonly.go index 2595b3a25a..bd082b02ed 100644 --- a/sei-tendermint/internal/autobahn/data/testonly.go +++ b/sei-tendermint/internal/autobahn/data/testonly.go @@ -60,8 +60,15 @@ func TestCommitQC( } var appQC utils.Option[*types.AppQC] if cqc, ok := prev.Get(); ok { - vs := types.ViewSpec{CommitQC: prev, Epoch: ep} - p := types.NewAppProposal(cqc.GlobalRange().Next-1, vs.View().Index, types.GenAppHash(rng), ep.EpochIndex()) + // AppQC certifies the prior tipcut (road == prev.Index), not the tipcut + // being built. buildProposal drops AppQCs whose block is not finalized; + // Verify additionally requires road < view. + p := types.NewAppProposal( + cqc.GlobalRange().Next-1, + cqc.Proposal().Index(), + types.GenAppHash(rng), + cqc.Proposal().EpochIndex(), + ) appQC = utils.Some(TestAppQC(keys, p)) } cqc := types.BuildCommitQC(ep, keys, prev, laneQCs, appQC) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 75191764d1..8d86ac31e3 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -1,83 +1,243 @@ package epoch import ( + "context" + "fmt" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) -type registryState struct { - m map[types.EpochIndex]*types.Epoch - latest types.EpochIndex +// EpochLength is the number of road indices per epoch. +const EpochLength types.RoadIndex = 108_000 + +// IndexForRoad returns the epoch index containing road. +func IndexForRoad(road types.RoadIndex) types.EpochIndex { + return types.EpochIndex(road / EpochLength) +} + +// FirstRoad returns the first road index of epoch idx. +func FirstRoad(idx types.EpochIndex) types.RoadIndex { + return types.RoadIndex(idx) * EpochLength +} + +// LastRoad returns the last road index of epoch idx (half-open Next-1). +func LastRoad(idx types.EpochIndex) types.RoadIndex { + return FirstRoad(idx+1) - 1 } -// Registry is the authoritative source of epoch and committee information. -// All layers (consensus, data, avail) read from it. +type registryState = map[types.EpochIndex]*types.Epoch + +// Registry is the authoritative catalog of epoch/committee metadata for all +// layers (consensus, data, avail). +// +// Invariants: +// - Catalog, not a live admit window. Each layer caches the epochs it needs +// to verify its own tip (avail: Current + App-anchor epoch; consensus: +// Current + Prev for AppQC lag). +// - Execution cannot pass commit. Sealing epoch N (including 0) requires +// registry N+1 (execution leash) and AppQC in epoch N before avail Current +// leaves N (prune leash). +// - data/ is the sole restart seeder (SetupInitialDuo). Avail/consensus must +// not seed; tip into an unseeded epoch → EpochAt/DuoAt hard-fail. +// - Post-construction tipcuts: avail ≥ consensus. Consensus and avail may +// lag data (peer FullCommitQC / async BlockDB flush); catch-up from peers +// and avail LastCommitQC in Run closes the gap. +// - Placeholders use the genesis committee until real committees are wired. +// - Genesis FirstBlock / FirstTimestamp live on Registry (from GenDoc), not +// on Epoch — per-epoch floors come from CommitQCs, which the registry does +// not store. +// +// TODO(autobahn): replace genesis placeholders with epoch info on blocks. type Registry struct { - state utils.RWMutex[registryState] + state utils.Watch[registryState] + // Genesis floors from GenDoc (InitialHeight / GenesisTime). + genesisFirstBlock types.GlobalBlockNumber + genesisTimestamp time.Time + genesisCommittee *types.Committee } -// NewRegistry creates a Registry with the genesis committee. +// NewRegistry creates a Registry with genesis epoch 0 only. +// Epoch 1+ are seeded by data.NewState via SetupInitialDuo. func NewRegistry( committee *types.Committee, firstBlock types.GlobalBlockNumber, genesisTimestamp time.Time, ) (*Registry, error) { - ep := types.NewEpoch(0, types.OpenRoadRange(), genesisTimestamp, committee, firstBlock) + ep := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) return &Registry{ - state: utils.NewRWMutex(registryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep}, - latest: 0, - }), + state: utils.NewWatch(registryState{0: ep}), + genesisFirstBlock: firstBlock, + genesisTimestamp: genesisTimestamp, + genesisCommittee: committee, }, nil } +// SetupInitialDuo seeds placeholder epochs on restart. Call only from +// data.NewState. Idempotent for existing entries. +// +// commitQCs is the half-open retained CommitQC range [First, Next). Seeds the +// duo at First, every epoch covering [First, Next), the duo at Next, then +// placeholder windowLast+1/+2 (see below). None = empty store → {0,1}; +// execution seeds epoch 2 before sealing epoch 0. Empty range (First >= Next) +// returns an error. +func (r *Registry) SetupInitialDuo(commitQCs utils.Option[types.RoadRange]) error { + if span, ok := commitQCs.Get(); ok { + if span.First >= span.Next { + return fmt.Errorf("SetupInitialDuo: empty CommitQC range [%d, %d)", span.First, span.Next) + } + windowFirst := IndexForRoad(span.First) + windowLast := IndexForRoad(span.Next - 1) + + // Avail WAL and BlockDB prune independently. Avail may restart at the + // retained span's first epoch and still need its Prev. + r.EnsureDuoAt(span.First) + for s, ctrl := range r.state.Lock() { + for idx := windowFirst; idx <= windowLast; idx++ { + if _, ok := s[idx]; ok { + continue + } + r.makeEpoch(s, idx) + ctrl.Updated() + } + } + r.EnsureDuoAt(span.Next) + // TODO(autobahn-placeholder-seed): always seed windowLast+1/+2 with + // genesis-committee stubs. Needed today because exec tip can sit ahead + // of persisted CommitQC (N+1) and tip at LastRoad(N) may need N+2 + // without re-exec. Drop once real committees are linked to execution + // and seed tipcut+1 only. + r.EnsureEpoch(windowLast + 1) + r.EnsureEpoch(windowLast + 2) + return nil + } + + r.EnsureDuoAt(FirstRoad(1)) + return nil +} + // FirstBlock returns the first global block number of the genesis epoch. // Used as the cold-start default (no WAL, no snapshot); WAL overrides this on restart. func (r *Registry) FirstBlock() types.GlobalBlockNumber { - for s := range r.state.RLock() { - return s.m[0].FirstBlock() + return r.genesisFirstBlock +} + +// FirstTimestamp returns the genesis timestamp (GenDoc.GenesisTime). +func (r *Registry) FirstTimestamp() time.Time { + return r.genesisTimestamp +} + +// EpochAt returns the epoch containing roadIndex. +// Error if that epoch is not registered. +func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { + epochIdx := IndexForRoad(roadIndex) + for s := range r.state.Lock() { + if ep, ok := s[epochIdx]; ok { + return ep, nil + } + return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) } panic("unreachable") } -// GenesisTimestamp returns the timestamp of the genesis epoch. -func (r *Registry) GenesisTimestamp() time.Time { - for s := range r.state.RLock() { - return s.m[0].FirstTimestamp() +// makeEpoch inserts a genesis-committee placeholder at epochIdx. +// Caller holds the write lock. Overwrites if present. Panics without epoch 0. +func (r *Registry) makeEpoch(s registryState, epochIdx types.EpochIndex) *types.Epoch { + if _, ok := s[0]; !ok { + panic("genesis epoch missing from registry") } - panic("unreachable") + firstRoad := FirstRoad(epochIdx) + epoch := types.NewEpoch(epochIdx, types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)}, r.genesisCommittee) + s[epochIdx] = epoch + return epoch } -// 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 +// EnsureEpoch registers a genesis-committee placeholder for idx if missing. +func (r *Registry) EnsureEpoch(idx types.EpochIndex) { + for s, ctrl := range r.state.Lock() { + if _, ok := s[idx]; !ok { + r.makeEpoch(s, idx) + ctrl.Updated() + } } - panic("unreachable") } -// LatestEpoch returns the most recently activated epoch. -func (r *Registry) LatestEpoch() *types.Epoch { - for s := range r.state.RLock() { - return s.m[s.latest] +// EnsureDuoAt ensures epochs for DuoAt(road): Current, and Prev when center > 0. +func (r *Registry) EnsureDuoAt(road types.RoadIndex) { + center := IndexForRoad(road) + if center > 0 { + r.EnsureEpoch(center - 1) } - panic("unreachable") + r.EnsureEpoch(center) } -// 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 +// AdvanceIfNeeded seeds epoch M+2 when roadIndex is LastRoad(M); else no-op. +// Also ensures M+1 so WaitForEpoch(M+1) is not stuck when sealing M. +// Call only after the last global of that road has executed (IsLastBlock). +// +// TODO(autobahn): pass the real M+2 committee once execution derives it. +// Until then placeholder committees may seed ahead of real execute results +// (including restart when app Commit leads blockDB flush). +func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { + tipEpoch := IndexForRoad(roadIndex) + if roadIndex != LastRoad(tipEpoch) { + return + } + r.EnsureEpoch(tipEpoch + 1) + r.EnsureEpoch(tipEpoch + 2) +} + +// DuoAt returns the EpochDuo centered on the epoch containing roadIndex. +// Current must already be registered. Prev absent only for epoch 0; missing +// Prev for center > 0 is a hard error (no soft-degrade to Current-only). +func (r *Registry) DuoAt(roadIndex types.RoadIndex) (types.EpochDuo, error) { + current, err := r.EpochAt(roadIndex) + if err != nil { + return types.EpochDuo{}, err + } + prev := utils.None[*types.Epoch]() + if current.EpochIndex() > 0 { + p, err := r.EpochAt(current.RoadRange().First - 1) + if err != nil { + return types.EpochDuo{}, fmt.Errorf("DuoAt(%d): Prev epoch missing: %w", roadIndex, err) + } + prev = utils.Some(p) + } + return types.NewEpochDuo(current, prev), nil +} + +// WaitForEpoch blocks until epoch i is registered. +// Must not hold the avail/data inner lock (execution may seed via AdvanceIfNeeded). +func (r *Registry) WaitForEpoch(ctx context.Context, i types.EpochIndex) (*types.Epoch, error) { + for inner, ctrl := range r.state.Lock() { + for { + if ep, ok := inner[i]; ok { + return ep, nil + } + if err := ctrl.Wait(ctx); err != nil { + return nil, err + } } - return []*types.Epoch{ep}, nil } panic("unreachable") } + +// WaitForDuo blocks until DuoAt(roadIndex) succeeds. +// Must not hold the avail/data inner lock (execution may seed via AdvanceIfNeeded). +func (r *Registry) WaitForDuo(ctx context.Context, roadIndex types.RoadIndex) (types.EpochDuo, error) { + i := IndexForRoad(roadIndex) + current, err := r.WaitForEpoch(ctx, i) + if err != nil { + return types.EpochDuo{}, err + } + prev := utils.None[*types.Epoch]() + if i > 0 { + p, err := r.WaitForEpoch(ctx, i-1) + if err != nil { + return types.EpochDuo{}, err + } + prev = utils.Some(p) + } + return types.NewEpochDuo(current, prev), nil +} diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index f5249bcd44..c8b881930f 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/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) func makeRegistry(t *testing.T) (*Registry, *types.Committee) { @@ -20,20 +21,246 @@ 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) - if _, ok := r.EpochByIndex(99); ok { - t.Fatal("EpochByIndex(99) returned ok, want not found") + ep, err := r.EpochAt(0) + if err != nil { + t.Fatalf("EpochAt(0): %v", err) + } + rng := ep.RoadRange() + if rng.First != 0 || rng.Next != FirstRoad(1) { + t.Fatalf("genesis RoadRange = {%d, %d}, want {0, %d}", rng.First, rng.Next, FirstRoad(1)) } } -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 seeds {0}. Only the last road of epoch 0 seeds epoch 2. + r.AdvanceIfNeeded(0) + if _, err := r.EpochAt(FirstRoad(2)); err == nil { + t.Fatal("AdvanceIfNeeded(0) must not seed epoch 2") + } + 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_EmptyNoneSeedsGenesisNeighbor(t *testing.T) { + r, _ := makeRegistry(t) + if err := r.SetupInitialDuo(utils.None[types.RoadRange]()); err != nil { + t.Fatal(err) + } + for _, idx := range []types.EpochIndex{0, 1} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after empty seeding: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(2)); err == nil { + t.Fatal("EpochAt(epoch 2) should not be present from empty None seeding") + } +} + +func TestSetupInitialDuo_EmptyRangeErrors(t *testing.T) { + r, _ := makeRegistry(t) + err := r.SetupInitialDuo(utils.Some(types.RoadRange{First: 5, Next: 5})) + if err == nil { + t.Fatal("empty CommitQC range must error") + } +} + +func TestSetupInitialDuo_CommitQCMidSeedsPlaceholderNext(t *testing.T) { + r, _ := makeRegistry(t) + // Mid-5 CommitQC + tipcut EnsureDuoAt → {4,5}; always through windowLast+2 → 6,7. + tip := midRoad(5) + if err := r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})); err != nil { + t.Fatal(err) + } + for _, idx := range []types.EpochIndex{4, 5, 6, 7} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after CommitQC seeding: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(8)); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present from mid-epoch CommitQC") + } + duo, err := r.DuoAt(FirstRoad(5)) + if err != nil { + t.Fatalf("DuoAt(FirstRoad(5)): %v", err) + } + prev, ok := duo.Prev.Get() + if !ok || prev.EpochIndex() != 4 { + t.Fatalf("DuoAt mid-epoch Prev = %v, want epoch 4", duo.Prev) + } + if duo.Current.EpochIndex() != 5 { + t.Fatalf("DuoAt mid-epoch Current = %d, want 5", duo.Current.EpochIndex()) + } +} + +func TestSetupInitialDuo_CommitQCClosingSeedsNext(t *testing.T) { + r, _ := makeRegistry(t) + // Closing tip: EnsureDuoAt(FirstRoad(6)) → {5,6}; windowLast+2 → 7. + tip := LastRoad(5) + if err := r.SetupInitialDuo(utils.Some(types.RoadRange{First: tip, Next: tip + 1})); err != nil { + t.Fatal(err) + } + for _, idx := range []types.EpochIndex{5, 6, 7} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after closing CommitQC: %v", idx, err) + } + } + if _, err := r.DuoAt(FirstRoad(6)); err != nil { + t.Fatalf("DuoAt(FirstRoad(6)) after closing CommitQC: %v", err) + } + if _, err := r.EpochAt(FirstRoad(8)); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present past windowLast+2") + } +} + +func TestSetupInitialDuo_CommitSpanFromFirst(t *testing.T) { + r, _ := makeRegistry(t) + // Span mid-2..mid-5 → seed the first duo {1,2}, epochs through 5, + // then placeholders through windowLast+2 → 7. + if err := r.SetupInitialDuo(utils.Some(types.RoadRange{ + First: midRoad(2), + Next: midRoad(5) + 1, + })); err != nil { + t.Fatal(err) + } + for _, idx := range []types.EpochIndex{1, 2, 3, 4, 5, 6, 7} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after commit span seeding: %v", idx, err) + } + } + if _, err := r.DuoAt(midRoad(2)); err != nil { + t.Fatalf("DuoAt(span.First) after commit span seeding: %v", err) + } + if _, err := r.EpochAt(FirstRoad(8)); err == nil { + t.Fatal("EpochAt(epoch 8) should not be present past placeholder windowLast+2") + } +} + +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.EpochIndex() != 0 { + t.Fatalf("DuoAt(0).Current.EpochIndex() wrong, want 0") + } +} + +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)}, committee) + bare := &Registry{ + state: utils.NewWatch(registryState{0: ep}), + } + _, err := bare.DuoAt(FirstRoad(1)) + if err == nil { + t.Fatal("DuoAt(FirstRoad(1)) expected error when Current epoch not registered, got nil") + } +} + +func TestDuoAt_ErrorWhenPrevMissing(t *testing.T) { + committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + types.GenSecretKey(utils.TestRng()).Public(): 1, + })) + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, committee) + ep2 := types.NewEpoch(2, types.RoadRange{First: FirstRoad(2), Next: FirstRoad(3)}, committee) + // Gap: epoch 2 present without epoch 1. + bare := &Registry{ + state: utils.NewWatch(registryState{0: ep0, 2: ep2}), + } + _, err := bare.DuoAt(FirstRoad(2)) + if err == nil { + t.Fatal("DuoAt(FirstRoad(2)) expected error when Prev epoch not registered, got nil") + } +} + +func TestWaitForDuo_FastPathAndWait(t *testing.T) { + r, _ := makeRegistry(t) + // NewRegistry seeds {0}; 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 {1,2} registered. + 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)) // seeds 1 and 2 + got := <-done + require.NoError(t, got.err) + require.Equal(t, types.EpochIndex(2), got.duo.Current.EpochIndex()) +} + +// TestWaitForDuo_WakesWhenPrevFilledAfterCurrent: Current already registered +// without Prev; EnsureEpoch(Prev) must unblock WaitForDuo (epochGen, not only highest). +func TestWaitForDuo_WakesWhenPrevFilledAfterCurrent(t *testing.T) { + r, _ := makeRegistry(t) + r.EnsureEpoch(2) // gap: {0,2}, no 1 + tip := FirstRoad(2) + _, err := r.DuoAt(tip) + require.Error(t, err) + + type result struct { + duo types.EpochDuo + err error } + done := make(chan result, 1) + go func() { + duo, err := r.WaitForDuo(t.Context(), tip) + done <- result{duo, err} + }() + r.EnsureEpoch(1) + got := <-done + require.NoError(t, got.err) + require.Equal(t, types.EpochIndex(2), got.duo.Current.EpochIndex()) + prev, ok := got.duo.Prev.Get() + require.True(t, ok) + require.Equal(t, types.EpochIndex(1), prev.EpochIndex()) } diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index c02099160f..65dbd71e88 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -7,17 +7,80 @@ 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 highest-index registered epoch. For use in tests only. +// Do not use this to stamp CommitQC views for an arbitrary road — use +// EpochAtTip (or EpochAt) so View.EpochIndex matches the road's epoch when +// GenRegistry starts away from genesis. +func (r *Registry) LatestEpoch() *types.Epoch { + for s := range r.state.Lock() { + var best types.EpochIndex + var ep *types.Epoch + for idx, e := range s { + if ep == nil || idx > best { + best = idx + ep = e + } + } + if ep == nil { + panic("registry has no epochs") + } + return ep + } + panic("unreachable") +} + +// EpochAtTip is the epoch for the next CommitQC after prev (road 0 if none). +// Intended for test CommitQC chains when GenRegistry's start epoch may be ≫ 0. +func (r *Registry) EpochAtTip(prev utils.Option[*types.CommitQC]) *types.Epoch { + return utils.OrPanic1(r.EpochAt(types.NextIndexOpt(prev))) +} + +// GenRegistry generates a random Registry of the given committee size, starting +// at a random epoch N ∈ [0, 100]. Seeds only the duo at N ({N−1 if N>0, N}), +// plus genesis 0 from NewRegistry — not M+1/M+2 placeholders and not a dense +// 0..N fill. startEpoch is drawn from rng.Split() so it does not depend on how +// many draws committee construction consumes. +// Callers building CommitQC chains must use EpochAtTip / EpochAt(road), not +// LatestEpoch(), for View.EpochIndex. Tests that need N+1/N+2 must EnsureEpoch +// (or AdvanceIfNeeded) themselves. +// Returns the registry, secret keys, and N. +// Intended for use in tests only. +func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.EpochIndex) { + startEpoch := types.EpochIndex(rng.Split().Intn(101)) //nolint:gosec + r, sks := GenRegistryAt(rng, size, startEpoch) + return r, sks, startEpoch +} + +// GenRegistryAt generates a Registry of the given committee size centered on +// startEpoch. Seeds only {startEpoch−1 (if >0), startEpoch} via EnsureDuoAt — +// not startEpoch+1/+2 (callers add those when needed). // 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 { weights[sk.Public()] = 1000 + uint64(rng.Intn(1000)) //nolint:gosec } committee := utils.OrPanic1(types.NewCommittee(weights)) - firstBlock := types.GenGlobalBlockNumber(rng) % 1000000 + // Production genesis starts at global block 0; randomizing this would detach + // empty-store CommitQC road 0 from the registry's genesis epoch. + const firstBlock types.GlobalBlockNumber = 0 + return makeRegistryAt(committee, firstBlock, startEpoch), sks +} + +// GenRegistryTip is GenRegistryAt on a random M ∈ [1, 100] so Prev is always +// present ({M−1, M} only). Prefer this over GenRegistryAt(..., 0) for tests that +// need a non-genesis Current. +func GenRegistryTip(rng utils.Rng, size int) (*Registry, []types.SecretKey, types.EpochIndex) { + m := types.EpochIndex(1 + rng.Split().Intn(100)) //nolint:gosec + r, sks := GenRegistryAt(rng, size, m) + return r, sks, m +} + +func makeRegistryAt(committee *types.Committee, firstBlock types.GlobalBlockNumber, startEpoch types.EpochIndex) *Registry { registry := utils.OrPanic1(NewRegistry(committee, firstBlock, time.Now())) - return registry, sks + // Duo at startEpoch only; no placeholder +1/+2 (unlike SetupInitialDuo's + // CommitQC-span path). Genesis 0 always exists from NewRegistry. + registry.EnsureDuoAt(FirstRoad(startEpoch)) + return registry } diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index f2560b2b4b..6e5fb4539e 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -231,7 +231,7 @@ func (env *testEnv) Run(ctx context.Context) error { } func newTestEnv(rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { - registry, keys := epoch.GenRegistry(rng, 1) + registry, keys, _ := epoch.GenRegistry(rng, 1) dataState := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, memblock.NewBlockDB())) consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ Key: keys[0], diff --git a/sei-tendermint/internal/p2p/giga/avail.go b/sei-tendermint/internal/p2p/giga/avail.go index b6a09ccdb3..40e8c7043f 100644 --- a/sei-tendermint/internal/p2p/giga/avail.go +++ b/sei-tendermint/internal/p2p/giga/avail.go @@ -197,7 +197,7 @@ func (x *Service) clientStreamCommitQCs(ctx context.Context, c rpc.Client[API]) return fmt.Errorf("types.CommitQCConv.Decode(): %w", err) } if err := x.validatorState().Avail().PushCommitQC(ctx, qc); err != nil { - return fmt.Errorf("s.PushFirstCommitQC(): %w", err) + return fmt.Errorf("s.PushCommitQC(): %w", err) } } } @@ -244,8 +244,8 @@ 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 { - return fmt.Errorf("s.PushFirstCommitQC(): %w", err) + if err := x.validatorState().Avail().PushAppQC(ctx, msg.AppQC, msg.CommitQC); err != nil { + return fmt.Errorf("s.PushAppQC(): %w", err) } } } diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 247809fbb9..8caeb0e719 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -18,7 +18,7 @@ import ( func TestAvailClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + registry, keys, _ := epoch.GenRegistry(rng, 4) committee := registry.LatestEpoch().Committee() env := newTestEnv(registry) var nodes []*testNode diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index a80fa5aa00..f21d668953 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. @@ -41,11 +41,12 @@ func TestConsensusClientServer(t *testing.T) { GlobalNumber: idx, FinalAppState: wantAppProposal, } + road := types.RoadIndex(offset) p := types.NewAppProposal( idx, - types.RoadIndex(offset), + road, types.GenAppHash(rng), - registry.LatestEpoch().EpochIndex(), + epoch.IndexForRoad(road), ) wantAppProposal = utils.Some(p) for _, n := range nodes { @@ -59,6 +60,8 @@ func TestConsensusClientServer(t *testing.T) { return fmt.Errorf("ds.QC(): %w", err) } want.Timestamp = qc.QC().Proposal().BlockTimestamp(idx).OrPanic("global block not in QC") + want.RoadIndex = qc.QC().Proposal().Index() + want.LastInCommitQC = qc.QC().GlobalRange().IsLastBlock(idx) if err := utils.TestDiff(want, got); err != nil { return err } diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 1e90e455ac..7acf76f2e3 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -97,31 +97,64 @@ 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) - env := newTestEnv(registry) - server := env.AddNode(keys[0]) - client := env.AddNode(keys[1]) - firstBlock := server.data.Registry().FirstBlock() + registry, keys, _ := epoch.GenRegistry(rng, 2) + serverData, err := data.NewState(&data.Config{Registry: registry}, memblock.NewBlockDB()) + if err != nil { + t.Fatal(err) + } + clientData, err := data.NewState(&data.Config{Registry: registry}, memblock.NewBlockDB()) + if err != nil { + t.Fatal(err) + } + serverService := NewBlockSyncService(serverData) + clientService := NewBlockSyncService(clientData) + firstBlock := serverData.Registry().FirstBlock() if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - s.SpawnBg(func() error { return env.Run(ctx) }) + serverConn, clientConn := conn.NewTestConn() + serverRPC := rpc.NewServer[API]() + clientRPC := rpc.NewClient[API]() + s.SpawnBgNamed("server data", func() error { + return utils.IgnoreAfterCancel(ctx, serverData.Run(ctx)) + }) + s.SpawnBgNamed("client data", func() error { + return utils.IgnoreAfterCancel(ctx, clientData.Run(ctx)) + }) + s.SpawnBgNamed("server service", func() error { + return utils.IgnoreAfterCancel(ctx, serverService.Run(ctx)) + }) + s.SpawnBgNamed("client service", func() error { + return utils.IgnoreAfterCancel(ctx, clientService.Run(ctx)) + }) + s.SpawnBgNamed("mux server", func() error { + return utils.IgnoreAfterCancel(ctx, serverRPC.Run(ctx, serverConn)) + }) + s.SpawnBgNamed("mux client", func() error { + return utils.IgnoreAfterCancel(ctx, clientRPC.Run(ctx, clientConn)) + }) + s.SpawnBgNamed("block sync server", func() error { + return utils.IgnoreAfterCancel(ctx, serverService.RunBlockSyncServer(ctx, serverRPC)) + }) + s.SpawnBgNamed("block sync client", func() error { + return utils.IgnoreAfterCancel(ctx, clientService.RunBlockSyncClient(ctx, clientRPC)) + }) t.Logf("push data") prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := data.TestCommitQC(rng, server.data.Registry().LatestEpoch(), keys, prev) - if err := server.data.PushQC(ctx, qc, blocks); err != nil { + qc, blocks := data.TestCommitQC(rng, serverData.Registry().EpochAtTip(prev), keys, prev) + if err := serverData.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("serverState.PushQC(): %w", err) } prev = utils.Some(qc.QC()) } t.Logf("wait for replication") - for n := firstBlock; n < server.data.NextBlock(); n++ { - want, err := server.data.GlobalBlock(ctx, n) + for n := firstBlock; n < serverData.NextBlock(); n++ { + want, err := serverData.GlobalBlock(ctx, n) if err != nil { return fmt.Errorf("serverState.FinalBlock(): %w", err) } - got, err := client.data.GlobalBlock(ctx, n) + got, err := clientData.GlobalBlock(ctx, n) if err != nil { return fmt.Errorf("clientState.FinalBlock(): %w", err) } @@ -129,11 +162,11 @@ func TestDataClientServer(t *testing.T) { return err } - wantQC, err := server.data.QC(ctx, n) + wantQC, err := serverData.QC(ctx, n) if err != nil { return fmt.Errorf("serverState.CommitQC(): %w", err) } - gotQC, err := client.data.QC(ctx, n) + gotQC, err := clientData.QC(ctx, n) if err != nil { return fmt.Errorf("clientState.CommitQC(): %w", err) } diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index ff8996c683..c2eae5a727 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -49,6 +49,27 @@ type gigaRouterCommon struct { inboundFullnodeCap int64 } +// newGigaRouterCommon assembles the shared router state both roles embed. The +// role-specific service (full RPC vs block-sync) is passed in; the connection +// pools, app handle, and inbound cap are the same for both. +func newGigaRouterCommon( + cfg *GigaRouterCommonConfig, + key NodeSecretKey, + dataState *data.State, + service *giga.Service, +) *gigaRouterCommon { + return &gigaRouterCommon{ + cfg: cfg, + key: key, + data: dataState, + service: service, + poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), + poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), + app: cfg.App, + inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), + } +} + // BuildDataState validates the common config, constructs the committee, and // returns an initialised data.State backed by blockDB. // @@ -259,6 +280,13 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo if err := r.data.PushAppHash(ctx, b.GlobalNumber, resp.AppHash); err != nil { return nil, fmt.Errorf("r.data.PushAppHash(%v): %w", b.GlobalNumber, err) } + // Seed N+2 when the last global of an epoch's closing road is executed. + // Road/last flags come from GlobalBlock (assembled with the covering QC) + // so we do not re-fetch QC after PushAppHash may have evicted it from RAM. + // TODO: real N+2 committee once execution derives it. + if b.LastInCommitQC { + r.data.Registry().AdvanceIfNeeded(b.RoadIndex) + } return commitResp, nil } @@ -527,6 +555,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_common_test.go b/sei-tendermint/internal/p2p/giga_router_common_test.go index 4e340a157c..a9b2ea38a7 100644 --- a/sei-tendermint/internal/p2p/giga_router_common_test.go +++ b/sei-tendermint/internal/p2p/giga_router_common_test.go @@ -94,7 +94,8 @@ func TestBuildDataStateStartsRecoveryAtAppTip(t *testing.T) { require.NoError(t, err) registry, err := epoch.NewRegistry(committee, atypes.GlobalBlockNumber(genDoc.InitialHeight), genDoc.GenesisTime) require.NoError(t, err) - qc, blocks := data.TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*atypes.CommitQC]()) + qc0, _ := data.TestCommitQC(rng, registry.EpochAtTip(utils.None[*atypes.CommitQC]()), keys, utils.None[*atypes.CommitQC]()) + qc, blocks := data.TestCommitQC(rng, registry.EpochAtTip(utils.Some(qc0.QC())), keys, utils.Some(qc0.QC())) gr := qc.QC().GlobalRange() require.Greater(t, gr.Len(), 2) last := gr.First + atypes.GlobalBlockNumber(gr.Len()/2) diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index 890b899845..7bcd17c8f7 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -9,7 +9,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) @@ -23,16 +22,7 @@ type gigaFullnodeRouter struct { func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey, dataState *data.State) (*gigaFullnodeRouter, error) { logger.Info("GigaRouter initialized (fullnode)", "validators", len(cfg.ValidatorAddrs), "dial_interval", cfg.DialInterval, "inbound_fullnode_cap", cfg.MaxInboundFullnodePeers) return &gigaFullnodeRouter{ - gigaRouterCommon: &gigaRouterCommon{ - cfg: cfg, - key: key, - data: dataState, - service: giga.NewBlockSyncService(dataState), - poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), - poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), - app: cfg.App, - inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), - }, + gigaRouterCommon: newGigaRouterCommon(cfg, key, dataState, giga.NewBlockSyncService(dataState)), }, nil } diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 1fe0fca384..9546ca95d8 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -41,19 +41,10 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey, dataSta 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 } @@ -100,7 +91,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { // shards, we proxy only while the target validator is currently connected; // otherwise we keep the tx local as a best-effort availability heuristic. func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*url.URL] { - shardValidator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := r.data.EpochDuo().Current.Committee().EvmShard(sender) if r.validatorKey == shardValidator { return utils.None[*url.URL]() } diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 90c641a4dc..7bf51376a4 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -15,6 +15,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/conn" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" @@ -204,6 +205,12 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { } require.Equal(t, gb.Payload.Txs(), rbBytes, "router[0].BlockByNumber(%v).Block.Data.Txs ≠ data.GlobalBlock(%v).Payload.Txs", h, h) } + // Empty-store SetupInitialDuo seeds genesis neighbor {0,1}; short run + // never finishes LastRoad(0), so AdvanceIfNeeded must not seed epoch 2. + _, err := giga0.data.Registry().EpochAt(epoch.FirstRoad(1)) + require.NoError(t, err, "empty-store seeding should register epoch 1 for WaitForDuo") + _, err = giga0.data.Registry().EpochAt(epoch.FirstRoad(2)) + require.Error(t, err, "epoch 2 should not be seeded before last road of epoch 0") return nil }) require.NoError(t, err) diff --git a/sei-tendermint/libs/utils/require/require.go b/sei-tendermint/libs/utils/require/require.go index 14d011dec5..1c53d65416 100644 --- a/sei-tendermint/libs/utils/require/require.go +++ b/sei-tendermint/libs/utils/require/require.go @@ -26,6 +26,9 @@ var NotZero = require.NotZero // Contains . var Contains = require.Contains +// NotContains . +var NotContains = require.NotContains + func ElementsMatch[T any](t TestingT, a []T, b []T, msgAndArgs ...any) { require.ElementsMatch(t, a, b, msgAndArgs...) }