From b705b04c15c20f57cb6a501a58175a6d09c4a628 Mon Sep 17 00:00:00 2001 From: Wen Date: Sun, 16 Aug 2026 15:57:11 -0700 Subject: [PATCH 01/19] feat(autobahn): complete multi-epoch ownership across layers (CON-358) Give data, avail, and consensus each one epoch step: data publishes CommitEpoch, avail advances the applied epoch and ConsensusSpec, and consensus restores from Spec. Seed genesis epochs 0 and 1, withhold Spec at boundaries instead of walking the tip back, and route EVM sharding through CommitEpoch. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/epoch.go | 4 +- sei-tendermint/autobahn/types/epoch_test.go | 8 + sei-tendermint/autobahn/types/proposal.go | 8 + .../internal/autobahn/avail/block_votes.go | 65 ++- .../autobahn/avail/block_votes_test.go | 76 +++ .../internal/autobahn/avail/inner.go | 165 ++++-- .../internal/autobahn/avail/inner_test.go | 471 +++++++++++------- .../internal/autobahn/avail/state.go | 156 ++++-- .../internal/autobahn/avail/state_test.go | 467 +++++++++++++++-- .../autobahn/avail/subscriptions_test.go | 169 +++++-- .../internal/autobahn/avail/testonly.go | 75 +++ .../internal/autobahn/consensus/inner.go | 72 ++- .../internal/autobahn/consensus/inner_test.go | 358 ++++++++++--- .../autobahn/consensus/persisted_inner.go | 6 - .../internal/autobahn/consensus/state.go | 36 +- .../internal/autobahn/data/state.go | 114 +++-- .../autobahn/data/state_recovery_test.go | 27 + .../internal/autobahn/data/state_test.go | 90 +++- .../internal/autobahn/epoch/registry.go | 172 +++++-- .../internal/autobahn/epoch/registry_test.go | 183 +++++++ .../autobahn/producer/mempool_test.go | 22 +- sei-tendermint/internal/p2p/giga/avail.go | 11 +- .../internal/p2p/giga_router_common.go | 4 + .../internal/p2p/giga_router_fullnode.go | 3 +- .../internal/p2p/giga_router_fullnode_test.go | 2 +- .../internal/p2p/giga_router_validator.go | 3 +- .../p2p/giga_router_validator_test.go | 2 +- 27 files changed, 2211 insertions(+), 558 deletions(-) create mode 100644 sei-tendermint/internal/autobahn/avail/block_votes_test.go diff --git a/sei-tendermint/autobahn/types/epoch.go b/sei-tendermint/autobahn/types/epoch.go index 5a19c4bc35..c4a73ad27f 100644 --- a/sei-tendermint/autobahn/types/epoch.go +++ b/sei-tendermint/autobahn/types/epoch.go @@ -9,17 +9,15 @@ import ( // EpochIndex is the epoch number. type EpochIndex uint64 -// RoadRange is an inclusive range of RoadIndex values [First, Last]. +// RoadRange is a half-open road range [First, Next). type RoadRange struct { First 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, Next: utils.Max[RoadIndex]()} } -// Has reports whether idx falls within this range. func (r RoadRange) Has(idx RoadIndex) bool { return r.First <= idx && idx < r.Next } // Epoch holds the complete context for a single epoch. diff --git a/sei-tendermint/autobahn/types/epoch_test.go b/sei-tendermint/autobahn/types/epoch_test.go index 13a5512630..35ed129e49 100644 --- a/sei-tendermint/autobahn/types/epoch_test.go +++ b/sei-tendermint/autobahn/types/epoch_test.go @@ -8,6 +8,14 @@ import ( "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 TestEpochIsClosed(t *testing.T) { rng := utils.TestRng() a := GenSecretKey(rng).Public() diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 5d7bbfd32e..5c3e562c1c 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -125,6 +125,14 @@ func (v View) Next() View { return v } +// ConsensusSpec is the durable CommitQC tip paired with the epoch of the view +// that follows it. Avail publishes Option[ConsensusSpec] (None until a tip +// exists); consensus installs Some values verbatim. +type ConsensusSpec struct { + CommitQC *CommitQC + Epoch *Epoch +} + // 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. diff --git a/sei-tendermint/internal/autobahn/avail/block_votes.go b/sei-tendermint/internal/autobahn/avail/block_votes.go index c380f12ccf..af3caf4b59 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes.go @@ -2,45 +2,76 @@ package avail import ( "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" ) +// LaneVotes have two jobs: byKey keeps headers for FullCommitQC; byHash +// accumulates applied-epoch weight for LaneQC. byKey is retained even after the +// latest CommitQC moves into a new epoch, since a FullCommitQC in the previous +// epoch may still need those headers — so we can't clear it on epoch change. +// qc is set once weight reaches quorum under the applied committee and cleared +// on reweight. type blockVotes struct { byKey map[types.PublicKey]*types.Signed[*types.LaneVote] byHash map[types.BlockHeaderHash]*voteSet[*types.Signed[*types.LaneVote]] + qc utils.Option[*types.LaneQC] } -func newBlockVotes() blockVotes { - return blockVotes{ +func newBlockVotes() *blockVotes { + return &blockVotes{ byKey: map[types.PublicKey]*types.Signed[*types.LaneVote]{}, byHash: map[types.BlockHeaderHash]*voteSet[*types.Signed[*types.LaneVote]]{}, } } -// Returns true iff a new QC has been constructed. -// Weight is counted under ep's committee (PushVote passes the applied epoch). -// TODO: votes accepted only under the Anchor epoch can have Weight 0 under the -// applied committee but are still appended into the LaneQC sig list; filter -// those out when forming a LaneQC (leave-across-epochs). -func (bv blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) (*types.LaneQC, bool) { - c := ep.Committee() +// return true iff the vote is newly stored in byKey. +func (bv *blockVotes) pushVote(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { k := vote.Key() - h := vote.Msg().Header().Hash() if _, ok := bv.byKey[k]; ok { - return nil, false + return false } bv.byKey[k] = vote + bv.credit(ep, vote) + return true +} + +func (bv *blockVotes) reweight(ep *types.Epoch) { + bv.qc = utils.None[*types.LaneQC]() + clear(bv.byHash) + for _, vote := range bv.byKey { + bv.credit(ep, vote) + } +} + +func (bv *blockVotes) credit(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) { + if bv.qc.IsPresent() { + return + } + c := ep.Committee() + k := vote.Key() + w := c.Weight(k) + if w == 0 { + return + } + h := vote.Msg().Header().Hash() byHash, ok := bv.byHash[h] if !ok { byHash = &voteSet[*types.Signed[*types.LaneVote]]{} bv.byHash[h] = byHash } - if byHash.weight >= c.LaneQuorum() { - return nil, false - } - byHash.weight += c.Weight(k) + byHash.weight += w byHash.votes = append(byHash.votes, vote) if byHash.weight >= c.LaneQuorum() { - return types.NewLaneQC(byHash.votes), true + bv.qc = utils.Some(types.NewLaneQC(byHash.votes)) + } +} + +func (bv *blockVotes) header(want types.BlockHeaderHash) utils.Option[*types.BlockHeader] { + for _, vote := range bv.byKey { + h := vote.Msg().Header() + if h.Hash() == want { + return utils.Some(h) + } } - return nil, false + return utils.None[*types.BlockHeader]() } 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..1c03e116d0 --- /dev/null +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -0,0 +1,76 @@ +package avail + +import ( + "testing" + "time" + + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" +) + +func TestBlockVotes_RecountStayFormsLaneQC(t *testing.T) { + rng := utils.TestRng() + a := types.GenSecretKey(rng) + b := types.GenSecretKey(rng) + c := types.GenSecretKey(rng) + d := types.GenSecretKey(rng) + + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 10}, time.Time{}, + utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + a.Public(): 1, b.Public(): 1, c.Public(): 1, d.Public(): 1, + })), 0) + lane := ep0.Committee().Lane(a.Public()).OrPanic("lane") + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() + vote := func(sk types.SecretKey) *types.Signed[*types.LaneVote] { + return types.Sign(sk, types.NewLaneVote(header)) + } + + bv := newBlockVotes() + require.True(t, bv.pushVote(ep0, vote(a))) + require.False(t, bv.qc.IsPresent()) + require.True(t, bv.pushVote(ep0, vote(b))) + qc, ok := bv.qc.Get() + require.True(t, ok) + require.Equal(t, header.Hash(), qc.Header().Hash()) + require.True(t, bv.pushVote(ep0, vote(d))) + + require.True(t, bv.header(header.Hash()).IsPresent()) + require.Equal(t, 3, len(bv.byKey)) + + ep1 := types.NewEpoch(1, types.RoadRange{First: 10, Next: 20}, time.Time{}, + utils.OrPanic1(ep0.Committee().DeriveNext(map[types.PublicKey]uint64{ + a.Public(): 1, b.Public(): 1, c.Public(): 1, + }, 1)), 0) + bv.reweight(ep1) + + qc, ok = bv.qc.Get() + require.True(t, ok) + require.Equal(t, header.Hash(), qc.Header().Hash()) + require.Equal(t, 3, len(bv.byKey)) + require.True(t, bv.header(header.Hash()).IsPresent()) +} + +func TestBlockVotes_ZeroWeightNotCreditedUnderApplied(t *testing.T) { + rng := utils.TestRng() + stay := types.GenSecretKey(rng) + leaver := types.GenSecretKey(rng) + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 10}, time.Time{}, + utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + stay.Public(): 1, leaver.Public(): 1, + types.GenSecretKey(rng).Public(): 1, types.GenSecretKey(rng).Public(): 1, + })), 0) + lane := ep0.Committee().Lane(stay.Public()).OrPanic("lane") + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() + + bv := newBlockVotes() + bv.pushVote(ep0, types.Sign(leaver, types.NewLaneVote(header))) + + ep1 := types.NewEpoch(1, types.RoadRange{First: 10, Next: 20}, time.Time{}, + utils.OrPanic1(ep0.Committee().DeriveNext(map[types.PublicKey]uint64{ + stay.Public(): 1, + }, 1)), 0) + bv.reweight(ep1) + require.False(t, bv.qc.IsPresent()) + require.Equal(t, 1, len(bv.byKey)) +} diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index b60636b8cd..2133220115 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -13,16 +13,17 @@ import ( // inner holds roads and per-LaneID block/vote maps. type inner struct { persistedCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] // latest persisted CommitQC + consensusSpec utils.AtomicSend[utils.Option[types.ConsensusSpec]] roads *queue[types.RoadIndex, *road] - // epoch is the applied (next-CommitQC) epoch. ApplyEpoch is the sole + // epoch is the applied (next-CommitQC) epoch. installEpoch is the sole // writer after construction. epoch utils.AtomicSend[*types.Epoch] - // anchorEpoch is the epoch of data's Anchor CommitQC. Before any Anchor - // exists it equals the applied epoch. - anchorEpoch *types.Epoch + // anchorEpoch is the epoch of data's Anchor CommitQC when one exists. + // None until the first Anchor arrives (construction prune or runEvict). + anchorEpoch utils.Option[*types.Epoch] blocks map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] - votes map[types.LaneID]*queue[types.BlockNumber, blockVotes] + 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 @@ -44,7 +45,6 @@ type inner struct { // requirement is what makes persist.contiguousSuffix safe: it silently drops // everything before the last hole it finds, so this is the only thing that // distinguishes a lazily pruned record from genuinely lost data. -// newInner requires both to be contiguous and returns an error on gaps. type loadedState struct { commitQCs []*types.CommitQC blocks map[types.LaneID][]persist.LoadedBlock @@ -54,11 +54,11 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { start := ds.Registry().LatestEpoch() i := &inner{ persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + consensusSpec: utils.NewAtomicSend(utils.None[types.ConsensusSpec]()), roads: newQueue[types.RoadIndex, *road](), epoch: utils.NewAtomicSend(start), - anchorEpoch: start, blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, - votes: map[types.LaneID]*queue[types.BlockNumber, blockVotes]{}, + votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, } for lane := range start.Committee().Lanes().All() { @@ -70,11 +70,7 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { // Apply the persisted prune anchor from the data.State. if anchor, ok := ds.Anchor().Load().Get(); ok { - ep, err := anchorEpochOf(ds.Registry(), anchor) - if err != nil { - return nil, err - } - i.prune(anchor, ep) + i.prune(anchor) } // Restore persisted CommitQCs. prune() may have already pushed the @@ -90,10 +86,18 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { if !ok { return nil, fmt.Errorf("epoch not found") } + if err := qc.Verify(epoch); err != nil { + return nil, fmt.Errorf("persisted commitQC %d verify: %w", qc.Index(), err) + } i.roads.pushBack(newRoad(qc, epoch)) } if i.roads.Len() > 0 { - i.persistedCommitQC.Store(utils.Some(i.roads.q[i.roads.next-1].commitQC)) + last := i.roads.q[i.roads.next-1] + i.persistedCommitQC.Store(utils.Some(last.commitQC)) + // Floor applied at the durable tip's verify-epoch. Bare Store on + // purpose: this is a rewind from LatestEpoch, not an install. + // The install loop below re-drives it from the durable leashes. + i.epoch.Store(last.epoch) } // Restore persisted blocks. Since the anchor is persisted first and @@ -128,38 +132,108 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { } i.nextBlockToPersist[lane] = q.next } + // Restart catch-up: install every epoch the durable leashes already allow. + // The live path (runEpochAdvance) installs one waited-for epoch at a time. + for { + next, ok := i.nextInstallableEpoch(ds).Get() + if !ok { + break + } + i.installEpoch(next) + } + i.refreshConsensusSpec() return i, nil } -// laneQC returns a LaneQC for (lane, n) if weight under the applied epoch's -// committee meets LaneQuorum. Does not reweight votes when the committee changes. -func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) (*types.LaneQC, bool) { - c := i.epoch.Load().Committee() - votes, ok := i.votes[lane] +// installEpoch makes ep the applied epoch: opens its lanes, reweights votes, +// and republishes ConsensusSpec. +func (i *inner) installEpoch(ep *types.Epoch) { + for lane := range ep.Committee().Lanes().All() { + i.addLane(lane) + } + i.reweightVotes(ep) + i.epoch.Store(ep) + i.refreshConsensusSpec() +} + +// leashesMet reports whether the applied epoch is sealed and its prune leash is +// met. Sealed means roads hold the epoch's last CommitQC. The prune leash is met +// when the Anchor epoch covers the applied epoch (an AppQC for that epoch +// exists). The execution leash — registry contains the next epoch — is checked +// separately so live waiters are not parked on avail's lock for a registry update. +func (i *inner) leashesMet() bool { + ep := i.epoch.Load() + if i.roads.next < ep.RoadRange().Next { + return false + } + ae, ok := i.anchorEpoch.Get() + return ok && ae.EpochIndex() >= ep.EpochIndex() +} + +// nextInstallableEpoch returns the next registry epoch when the applied epoch +// is sealed, its prune leash is met, and the execution leash is met (next epoch +// registered). +func (i *inner) nextInstallableEpoch(ds *data.State) utils.Option[*types.Epoch] { + if !i.leashesMet() { + return utils.None[*types.Epoch]() + } + next, ok := ds.Registry().EpochByIndex(i.epoch.Load().EpochIndex() + 1) if !ok { - return nil, false + return utils.None[*types.Epoch]() } - entry, ok := votes.q[n] + return utils.Some(next) +} + +// refreshConsensusSpec publishes ConsensusSpec for the durable tip, paired with +// the epoch of the view that follows it. The spec is withheld — the previously +// published one stands — until that epoch is applied and resolvable. +// +// Withholding rather than publishing an earlier tip is what keeps the spec +// monotonic. At an epoch boundary the durable tip sits on LastRoad(E) while +// applied is still E, and a node that already entered E+1 before a restart must +// not be handed a predecessor of the tip it holds: installing it would roll the +// view backwards and discard that view's votes. +func (i *inner) refreshConsensusSpec() { + cqc, ok := i.persistedCommitQC.Load().Get() + if !ok { + return + } + next := cqc.Index() + 1 + if epoch.IndexForRoad(next) > i.epoch.Load().EpochIndex() { + return + } + ep, ok := i.epochForRoad(next).Get() if !ok { - return nil, false + return + } + i.consensusSpec.Store(utils.Some(types.ConsensusSpec{CommitQC: cqc, Epoch: ep})) +} + +func (i *inner) epochForRoad(road types.RoadIndex) utils.Option[*types.Epoch] { + if ep := i.epoch.Load(); ep.RoadRange().Has(road) { + return utils.Some(ep) + } + if road >= i.roads.first && road < i.roads.next { + return utils.Some(i.roads.q[road].epoch) } - for _, byHash := range entry.byHash { - if byHash.weight >= c.LaneQuorum() { - return types.NewLaneQC(byHash.votes[:]), true + // Persist may lag installEpoch: tip's next view can sit in an earlier epoch + // still present on some admitted road. + for idx := i.roads.first; idx < i.roads.next; idx++ { + if ep := i.roads.q[idx].epoch; ep.RoadRange().Has(road) { + return utils.Some(ep) } } - return nil, false + return utils.None[*types.Epoch]() } -// addLane ensures empty block/vote queues and a zero persist cursor for lane. -// No-op if the lane is already present. -func (i *inner) addLane(lane types.LaneID) { +func (i *inner) addLane(lane types.LaneID) bool { if _, ok := i.blocks[lane]; ok { - return + return false } i.blocks[lane] = newQueue[types.BlockNumber, *types.Signed[*types.LaneProposal]]() - i.votes[lane] = newQueue[types.BlockNumber, blockVotes]() + i.votes[lane] = newQueue[types.BlockNumber, *blockVotes]() i.nextBlockToPersist[lane] = 0 + return true } func (i *inner) dropLanes(lanes []types.LaneID) int { @@ -176,19 +250,30 @@ func (i *inner) dropLanes(lanes []types.LaneID) int { return n } -// anchorEpochOf returns the epoch of anchor's CommitQC from the registry. -func anchorEpochOf(registry *epoch.Registry, anchor data.Anchor) (*types.Epoch, error) { - ei := anchor.CommitQC.Proposal().EpochIndex() - ep, ok := registry.EpochByIndex(ei) +func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { + votes, ok := i.votes[lane] + if !ok { + return utils.None[*types.LaneQC]() + } + entry, ok := votes.q[n] if !ok { - return nil, fmt.Errorf("unknown epoch_index %d for anchor", ei) + return utils.None[*types.LaneQC]() + } + return entry.qc +} + +func (i *inner) reweightVotes(ep *types.Epoch) { + for _, vq := range i.votes { + for n := vq.first; n < vq.next; n++ { + vq.q[n].reweight(ep) + } } - return ep, nil } // prune advances the state up to the data Anchor and drops lanes closed as of -// anchorEpoch. Returns the number of lanes dropped. -func (i *inner) prune(anchor data.Anchor, anchorEpoch *types.Epoch) int { +// anchor.Epoch. Returns the number of lanes dropped. +func (i *inner) prune(anchor data.Anchor) int { + anchorEpoch := anchor.Epoch idx := anchor.CommitQC.Index() if idx >= i.roads.first { i.roads.prune(idx + 1) @@ -205,7 +290,7 @@ func (i *inner) prune(anchor data.Anchor, anchorEpoch *types.Epoch) int { i.persistedCommitQC.Store(utils.Some(anchor.CommitQC)) } } - i.anchorEpoch = anchorEpoch + i.anchorEpoch = utils.Some(anchorEpoch) var closed []types.LaneID for lane := range i.blocks { if anchorEpoch.IsClosed(lane) { diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 2c36cc3846..e6d99ed888 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -1,6 +1,7 @@ package avail import ( + "context" "testing" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/memblock" @@ -9,7 +10,8 @@ import ( "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/libs/utils" - "github.com/stretchr/testify/require" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) func newTestDataState(cfg *data.Config) *data.State { @@ -21,7 +23,18 @@ func testSignedBlock(key types.SecretKey, lane types.LaneID, n types.BlockNumber return types.Sign(key, types.NewLaneProposal(block)) } -func TestNewInnerFreshStart(t *testing.T) { +func contiguousBlocks(key types.SecretKey, lane types.LaneID, n int, rng utils.Rng) []persist.LoadedBlock { + var parent types.BlockHeaderHash + bs := make([]persist.LoadedBlock, 0, n) + for i := range types.BlockNumber(n) { + b := testSignedBlock(key, lane, i, parent, rng) + parent = b.Msg().Block().Header().Hash() + bs = append(bs, persist.LoadedBlock{Number: i, Proposal: b}) + } + return bs +} + +func TestNewInner_Empty(t *testing.T) { rng := utils.TestRng() registry, _ := epoch.GenRegistry(rng, 4) @@ -30,6 +43,8 @@ func TestNewInnerFreshStart(t *testing.T) { require.Equal(t, types.RoadIndex(0), i.roads.first) require.Equal(t, types.RoadIndex(0), i.roads.next) + _, ok := i.persistedCommitQC.Load().Get() + require.False(t, ok) require.NotNil(t, i.nextBlockToPersist) for lane := range registry.LatestEpoch().Committee().Lanes().All() { require.Equal(t, types.BlockNumber(0), i.blocks[lane].first) @@ -39,211 +54,309 @@ func TestNewInnerFreshStart(t *testing.T) { } } -func TestNewInnerLoadedBlocksContiguous(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - lane := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") - - var parent types.BlockHeaderHash - var bs []persist.LoadedBlock - for n := range types.BlockNumber(3) { - b := testSignedBlock(keys[0], lane, n, parent, rng) - parent = b.Msg().Block().Header().Hash() - bs = append(bs, persist.LoadedBlock{Number: n, Proposal: b}) - } +func TestNewInner_LoadedBlocks(t *testing.T) { + t.Run("contiguous", func(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + ds := newTestDataState(&data.Config{Registry: registry}) + bs := contiguousBlocks(keys[0], lane0, 3, rng) + i, err := newInner(ds, &loadedState{blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs}}) + require.NoError(t, err) + q := i.blocks[lane0] + 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)]) + } + require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane0]) + for other := range registry.LatestEpoch().Committee().Lanes().All() { + if other != lane0 { + require.Equal(t, types.BlockNumber(0), i.nextBlockToPersist[other]) + } + } + }) - i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ - blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, + t.Run("empty slice", func(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ + blocks: map[types.LaneID][]persist.LoadedBlock{lane0: {}}, + }) + require.NoError(t, err) + q := i.blocks[lane0] + require.Equal(t, types.BlockNumber(0), q.first) + require.Equal(t, types.BlockNumber(0), q.next) }) - require.NoError(t, err) - q := i.blocks[lane] - 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)]) - } - require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane]) - for other := range registry.LatestEpoch().Committee().Lanes().All() { - if other != lane { - require.Equal(t, types.BlockNumber(0), i.nextBlockToPersist[other]) + t.Run("foreign loaded lane does not touch committee queues", func(t *testing.T) { + rng := utils.TestRng() + registry, _ := epoch.GenRegistry(rng, 4) + unknownKey := types.GenSecretKey(rng) + unknownLane := types.LaneID{Validator: unknownKey.Public(), Joined: 0} + b := testSignedBlock(unknownKey, unknownLane, 0, types.BlockHeaderHash{}, rng) + i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ + blocks: map[types.LaneID][]persist.LoadedBlock{unknownLane: {{Number: 0, Proposal: b}}}, + }) + require.NoError(t, err) + q := i.blocks[unknownLane] + require.Equal(t, types.BlockNumber(0), q.first) + require.Equal(t, types.BlockNumber(1), q.next) + require.Equal(t, b, q.q[0]) + for lane := range registry.LatestEpoch().Committee().Lanes().All() { + cq := i.blocks[lane] + require.Equal(t, types.BlockNumber(0), cq.first) + require.Equal(t, types.BlockNumber(0), cq.next) } - } -} - -func TestNewInnerLoadedBlocksEmptySlice(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - lane := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") - - i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ - blocks: map[types.LaneID][]persist.LoadedBlock{lane: {}}, }) - require.NoError(t, err) - q := i.blocks[lane] - 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) + t.Run("multiple lanes", func(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + lane1 := registry.LatestEpoch().Committee().Lane(keys[1].Public()).OrPanic("keys[1]") + bs0 := contiguousBlocks(keys[0], lane0, 2, rng) + bs1 := contiguousBlocks(keys[1], lane1, 3, rng) + i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ + blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs0, lane1: bs1}, + }) + require.NoError(t, err) + require.Equal(t, types.BlockNumber(2), i.blocks[lane0].next) + require.Equal(t, types.BlockNumber(3), i.blocks[lane1].next) + require.Equal(t, types.BlockNumber(2), i.nextBlockToPersist[lane0]) + require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane1]) + }) - unknownKey := types.GenSecretKey(rng) - unknownLane := types.LaneID{Validator: unknownKey.Public(), Joined: 0} - b := testSignedBlock(unknownKey, unknownLane, 0, types.BlockHeaderHash{}, rng) + t.Run("gap", func(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + var bs []persist.LoadedBlock + for _, n := range []types.BlockNumber{3, 4, 6, 7} { + bs = append(bs, persist.LoadedBlock{Number: n, Proposal: testSignedBlock(keys[0], lane0, n, types.BlockHeaderHash{}, rng)}) + } + _, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ + blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "non-contiguous") + }) - i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ - blocks: map[types.LaneID][]persist.LoadedBlock{unknownLane: {{Number: 0, Proposal: b}}}, + t.Run("parent hash mismatch", func(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + var parent types.BlockHeaderHash + b0 := testSignedBlock(keys[0], lane0, 0, parent, rng) + parent = b0.Msg().Block().Header().Hash() + b1 := testSignedBlock(keys[0], lane0, 1, parent, rng) + b2 := testSignedBlock(keys[0], lane0, 2, types.GenBlockHeaderHash(rng), rng) + _, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{blocks: map[types.LaneID][]persist.LoadedBlock{lane0: { + {Number: 0, Proposal: b0}, + {Number: 1, Proposal: b1}, + {Number: 2, Proposal: b2}, + }}}) + require.Error(t, err) + require.Contains(t, err.Error(), "parent hash mismatch") }) - require.NoError(t, err) - for lane := range registry.LatestEpoch().Committee().Lanes().All() { - q := i.blocks[lane] - require.Equal(t, types.BlockNumber(0), q.first) - require.Equal(t, types.BlockNumber(0), q.next) - } - _ = keys + t.Run("over capacity", func(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + bs := contiguousBlocks(keys[0], lane0, BlocksPerLane+5, rng) + _, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ + blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds capacity") + }) } -func TestNewInnerLoadedBlocksMultipleLanes(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") - lane1 := registry.LatestEpoch().Committee().Lane(keys[1].Public()).OrPanic("keys[1]") - - var parent0 types.BlockHeaderHash - var bs0 []persist.LoadedBlock - for n := range types.BlockNumber(2) { - b := testSignedBlock(keys[0], lane0, n, parent0, rng) - parent0 = b.Msg().Block().Header().Hash() - bs0 = append(bs0, persist.LoadedBlock{Number: n, Proposal: b}) - } - - var parent1 types.BlockHeaderHash - var bs1 []persist.LoadedBlock - for n := range types.BlockNumber(3) { - b := testSignedBlock(keys[1], lane1, n, parent1, rng) - parent1 = b.Msg().Block().Header().Hash() - bs1 = append(bs1, persist.LoadedBlock{Number: n, Proposal: b}) - } - - i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ - blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs0, lane1: bs1}, +func TestNewInner_LoadedCommitQCs(t *testing.T) { + t.Run("contiguous", func(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + ds := newTestDataState(&data.Config{Registry: registry}) + qcs := make([]*types.CommitQC, 3) + prev := utils.None[*types.CommitQC]() + for i := range qcs { + qcs[i] = types.BuildCommitQC(registry.LatestEpoch(), keys, prev, nil) + prev = utils.Some(qcs[i]) + } + inner, err := newInner(ds, &loadedState{commitQCs: qcs}) + require.NoError(t, err) + require.Equal(t, types.RoadIndex(0), inner.roads.first) + require.Equal(t, types.RoadIndex(3), inner.roads.next) + for i, qc := range qcs { + require.NoError(t, utils.TestDiff(qc, inner.roads.q[types.RoadIndex(i)].commitQC)) + } + require.NoError(t, utils.TestDiff(utils.Some(qcs[2]), inner.persistedCommitQC.Load())) }) - require.NoError(t, err) - require.Equal(t, types.BlockNumber(2), i.blocks[lane0].next) - require.Equal(t, types.BlockNumber(3), i.blocks[lane1].next) - require.Equal(t, types.BlockNumber(2), i.nextBlockToPersist[lane0]) - require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane1]) + t.Run("gap", func(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + ds := newTestDataState(&data.Config{Registry: registry}) + qc0 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.None[*types.CommitQC](), nil) + qc1 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.Some(qc0), nil) + qc2 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.Some(qc1), nil) + _, err := newInner(ds, &loadedState{commitQCs: []*types.CommitQC{qc0, qc2}}) + require.Error(t, err) + require.Contains(t, err.Error(), "non-contiguous") + }) } -func TestNewInnerLoadedCommitQCsNoAppQC(t *testing.T) { +func TestAddLane_ReportsNewLaneForEachMembershipPeriod(t *testing.T) { rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) + a := types.GenSecretKey(rng) - qcs := make([]*types.CommitQC, 3) - prev := utils.None[*types.CommitQC]() - for i := range qcs { - qcs[i] = types.BuildCommitQC(registry.LatestEpoch(), keys, prev, nil) - prev = utils.Some(qcs[i]) + i := &inner{ + blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, + votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, + nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, } - - inner, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{commitQCs: qcs}) - require.NoError(t, err) - - require.Equal(t, types.RoadIndex(0), inner.roads.first) - require.Equal(t, types.RoadIndex(3), inner.roads.next) - for i, qc := range qcs { - require.NoError(t, utils.TestDiff(qc, inner.roads.q[types.RoadIndex(i)].commitQC)) - } - require.NoError(t, utils.TestDiff(utils.Some(qcs[2]), inner.persistedCommitQC.Load())) + lane := types.LaneID{Validator: a.Public(), Joined: 1} + require.True(t, i.addLane(lane)) + require.False(t, i.addLane(lane)) + require.True(t, i.addLane(types.LaneID{Validator: a.Public(), Joined: 3})) } -func TestNewInnerLoadedCommitQCsGapReturnsError(t *testing.T) { +// TestNextInstallableEpoch_BoundaryTipUsesDataAppQC: tip at LastRoad(0) with +// applied floored to 0 (restart), data's Anchor already covers epoch 0, registry +// has epoch 1 → install walks to 1 so ConsensusSpec republishes the tip. +// This is the avail half of the blind-Spec restore invariant: consensus may +// refuse to start if Spec stays behind a WAL tip at LastRoad(0) after catch-up. +func TestNextInstallableEpoch_BoundaryTipUsesDataAppQC(t *testing.T) { + ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - - qc0 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.None[*types.CommitQC](), nil) - qc1 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.Some(qc0), nil) - qc2 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.Some(qc1), nil) - - _, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{commitQCs: []*types.CommitQC{qc0, qc2}}) - require.Error(t, err) - require.Contains(t, err.Error(), "non-contiguous") -} - -func TestNewInnerLoadedCommitQCsEmpty(t *testing.T) { - rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 4) - - inner, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{}) - require.NoError(t, err) - - require.Equal(t, types.RoadIndex(0), inner.roads.first) - require.Equal(t, types.RoadIndex(0), inner.roads.next) - _, ok := inner.persistedCommitQC.Load().Get() - require.False(t, ok) -} - -func TestNewInnerLoadedBlocksGapReturnsError(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 4) - lane := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") - - var bs []persist.LoadedBlock - for _, n := range []types.BlockNumber{3, 4, 6, 7} { - bs = append(bs, persist.LoadedBlock{Number: n, Proposal: testSignedBlock(keys[0], lane, n, types.BlockHeaderHash{}, rng)}) - } - - _, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ - blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, + ds := newTestDataState(&data.Config{Registry: registry}) + + ep0, ok := registry.EpochByIndex(0) + require.True(t, ok) + + 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)) + }) + qc0, blocks := data.TestCommitQC(rng, ep0, keys, utils.None[*types.CommitQC]()) + if err := ds.PushQC(ctx, qc0, blocks); err != nil { + return err + } + if err := ds.PushAppHash(ctx, qc0.QC().GlobalRange().Next-1, types.GenAppHash(rng)); err != nil { + return err + } + vote, err := ds.AppVote(ctx, qc0.QC().GlobalRange().First) + if err != nil { + return err + } + if err := ds.PushAppQC(ctx, data.TestAppQC(keys, vote.Proposal())); err != nil { + return err + } + _, err = ds.Anchor().Wait(ctx, func(a utils.Option[data.Anchor]) bool { + got, ok := a.Get() + return ok && got.CommitQC.Index() == qc0.QC().Index() + }) + return err + })) + anchor, ok := ds.Anchor().Load().Get() + require.True(t, ok) + require.Equal(t, types.EpochIndex(0), anchor.Epoch.EpochIndex()) + + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok) + + last := epoch.LastRoad(0) + prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}))), }) - 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) - lane := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") - - var parent types.BlockHeaderHash - b0 := testSignedBlock(keys[0], lane, 0, parent, rng) - parent = b0.Msg().Block().Header().Hash() - b1 := testSignedBlock(keys[0], lane, 1, parent, rng) - b2 := testSignedBlock(keys[0], lane, 2, types.GenBlockHeaderHash(rng), rng) + qcLast := types.BuildCommitQC(ep0, keys, utils.Some(prev), nil) + require.Equal(t, last, qcLast.Index()) + + i := &inner{ + persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + consensusSpec: utils.NewAtomicSend(utils.None[types.ConsensusSpec]()), + roads: newQueue[types.RoadIndex, *road](), + epoch: utils.NewAtomicSend(ep0), + anchorEpoch: utils.Some(anchor.Epoch), + blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, + votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, + nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, + } + for lane := range ep0.Committee().Lanes().All() { + i.addLane(lane) + } + i.roads.first = last + i.roads.next = last + i.roads.pushBack(newRoad(qcLast, ep0)) + i.persistedCommitQC.Store(utils.Some(qcLast)) + i.epoch.Store(ep0) + + require.False(t, i.roads.q[last].appQC.IsPresent(), "road AppQC empty; prune leash is the Anchor") + require.True(t, i.leashesMet()) + require.True(t, i.nextInstallableEpoch(ds).IsPresent()) + require.Equal(t, types.EpochIndex(1), i.nextInstallableEpoch(ds).OrPanic("installable").EpochIndex()) + + for { + next, ok := i.nextInstallableEpoch(ds).Get() + if !ok { + break + } + i.installEpoch(next) + } + i.refreshConsensusSpec() - _, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ - blocks: map[types.LaneID][]persist.LoadedBlock{lane: { - {Number: 0, Proposal: b0}, - {Number: 1, Proposal: b1}, - {Number: 2, Proposal: b2}, - }}, - }) - require.Error(t, err) - require.Contains(t, err.Error(), "parent hash mismatch") + require.Equal(t, ep1.EpochIndex(), i.epoch.Load().EpochIndex()) + spec, ok := i.consensusSpec.Load().Get() + require.True(t, ok) + require.Equal(t, last, spec.CommitQC.Index(), "must not walk tip back to LastRoad(0)-1") + require.Equal(t, types.EpochIndex(1), spec.Epoch.EpochIndex()) } -func TestNewInnerLoadedBlocksOverCapacityReturnsError(t *testing.T) { +// TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied: the durable tip +// sits on LastRoad(0) while applied is still epoch 0, and the tip's predecessor is +// retained. The spec must be withheld rather than published at that predecessor — +// a node that already entered epoch 1 holds the tip and must not be rolled back. +func TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - lane := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") - - count := BlocksPerLane + 5 - var parent types.BlockHeaderHash - var bs []persist.LoadedBlock - for n := types.BlockNumber(0); n < types.BlockNumber(count); n++ { - b := testSignedBlock(keys[0], lane, n, parent, rng) - parent = b.Msg().Block().Header().Hash() - bs = append(bs, persist.LoadedBlock{Number: n, Proposal: b}) + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + + ep0, ok := registry.EpochByIndex(0) + require.True(t, ok) + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok) + + last := epoch.LastRoad(0) + qcPrev := types.BuildCommitQC(ep0, keys, utils.Some(tipLink(ep0, keys[0], last-2)), nil) + qcLast := types.BuildCommitQC(ep0, keys, utils.Some(qcPrev), nil) + require.Equal(t, last-1, qcPrev.Index()) + require.Equal(t, last, qcLast.Index()) + + i := &inner{ + persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + consensusSpec: utils.NewAtomicSend(utils.None[types.ConsensusSpec]()), + roads: newQueue[types.RoadIndex, *road](), + epoch: utils.NewAtomicSend(ep0), + blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, + votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, + nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, } - - _, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ - blocks: map[types.LaneID][]persist.LoadedBlock{lane: bs}, - }) - require.Error(t, err) - require.Contains(t, err.Error(), "exceeds capacity") + i.roads.first = last - 1 + i.roads.next = last - 1 + i.roads.pushBack(newRoad(qcPrev, ep0)) + i.roads.pushBack(newRoad(qcLast, ep0)) + i.persistedCommitQC.Store(utils.Some(qcLast)) + + i.refreshConsensusSpec() + require.False(t, i.consensusSpec.Load().IsPresent(), "spec must be withheld, not published at the predecessor") + + i.installEpoch(ep1) + spec, ok := i.consensusSpec.Load().Get() + require.True(t, ok) + require.Equal(t, last, spec.CommitQC.Index()) + require.Equal(t, types.EpochIndex(1), spec.Epoch.EpochIndex()) } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 838ffee926..8b4aed36ed 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -35,9 +35,6 @@ const BlocksPerLane = 3 * types.MaxLaneRangeInProposal // Lane maps and WALs outlive committee membership until the anchor epoch // IsClosed that LaneID. Before any Anchor exists, the anchor epoch is the // applied epoch. -// -// Per-lane vote queues supply LaneQC weight under the applied epoch, and -// FullCommitQC headers until the anchor epoch IsClosed that LaneID. type State struct { key types.SecretKey data *data.State @@ -54,7 +51,7 @@ func (s *State) PublicKey() types.PublicKey { return s.key.Public() } -// Epoch returns the applied (next-CommitQC) epoch. ApplyEpoch advances it. +// Epoch returns the applied (next-CommitQC) epoch. runEpochAdvance advances it. func (s *State) Epoch() utils.AtomicRecv[*types.Epoch] { return s.epoch } @@ -78,16 +75,6 @@ func (s *State) WaitUntilClosed(ctx context.Context, lane types.LaneID) error { return err } -func (s *State) ApplyEpoch(ep *types.Epoch) { - for inner, ctrl := range s.inner.Lock() { - for lane := range ep.Committee().Lanes().All() { - inner.addLane(lane) - } - inner.epoch.Store(ep) - ctrl.Updated() - } -} - // 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. @@ -185,6 +172,15 @@ func (s *State) LastCommitQC() utils.AtomicRecv[utils.Option[*types.CommitQC]] { panic("unreachable") } +// SubscribeConsensusSpec returns a receiver of the durable CommitQC tip paired +// with the epoch governing the view that follows it. None until a tip exists. +func (s *State) SubscribeConsensusSpec() utils.AtomicRecv[utils.Option[types.ConsensusSpec]] { + for inner := range s.inner.Lock() { + return inner.consensusSpec.Subscribe() + } + panic("unreachable") +} + func (s *State) appQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, error) { for inner, ctrl := range s.inner.Lock() { for { @@ -223,10 +219,32 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi return qc, err } -// PushCommitQC pushes a CommitQC to the state. -// Waits until all previous CommitQCs are pushed. +// waitUntilApplied blocks until the applied (next-CommitQC) epoch equals i. +// Returns ErrPruned if applied has already passed i (see types.ErrPruned). +func (s *State) waitUntilApplied(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 +} + +// PushCommitQC admits a CommitQC once its epoch is applied (ingress wait). +// Stale QCs are a no-op. func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() + epoch, err := s.waitUntilApplied(ctx, qc.Proposal().EpochIndex()) + if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } + return err + } for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { return idx <= inner.roads.next }); err != nil { return err @@ -235,10 +253,6 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { return nil } } - epoch, ok := s.data.Registry().EpochByIndex(qc.Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.Proposal().EpochIndex()) - } if err := qc.Verify(epoch); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } @@ -323,22 +337,20 @@ func (s *State) Block(ctx context.Context, lane types.LaneID, n types.BlockNumbe // Waits until all previous blocks are available. // A missing map (closed lane, or a LaneID never admitted) is a silent no-op — // future lanes are not waited on. +// Accepts a proposal valid under the applied or Anchor epoch. func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LaneProposal]) error { h := p.Msg().Block().Header() if p.Key() != h.Lane().Validator { 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) - } lane := h.Lane() n := h.BlockNumber() for inner, ctrl := range s.inner.Lock() { + if !laneAcceptedUnder(inner, func(ep *types.Epoch) bool { + return laneProposalAcceptedByEpoch(ep, p) + }) { + return nil + } if err := ctrl.WaitUntil(ctx, func() bool { q, ok := inner.blocks[lane] if !ok { @@ -385,8 +397,8 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos // 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. -// Accepts a vote valid under the applied epoch or the Anchor epoch; LaneQC -// weight always uses the applied epoch. +// Accepts a vote valid under the applied or Anchor epoch (header evidence may +// come from either); LaneQC weight uses only the applied epoch. func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote]) error { h := vote.Msg().Header() lane := h.Lane() @@ -409,28 +421,48 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return nil } applied := inner.epoch.Load() - // TODO: return a meaningful validation error when the vote - // matches neither epoch, or when verification fails under a matching epoch. - if !laneVoteAccepted(applied, vote) && - (applied.EpochIndex() == inner.anchorEpoch.EpochIndex() || !laneVoteAccepted(inner.anchorEpoch, vote)) { + // TODO: accept future-epoch joiner votes. + if !laneAcceptedUnder(inner, func(ep *types.Epoch) bool { + return laneVoteAcceptedByEpoch(ep, vote) + }) { return nil } for q.next <= n { q.pushBack(newBlockVotes()) } - if _, ok := q.q[n].pushVote(applied, vote); ok { + if q.q[n].pushVote(applied, vote) { ctrl.Updated() } } return nil } -// laneVoteAccepted reports whether vote verifies under ep's committee. -func laneVoteAccepted(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { +// laneVoteAcceptedByEpoch reports whether vote verifies under ep's committee. +func laneVoteAcceptedByEpoch(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { c := ep.Committee() return vote.Msg().Verify(c) == nil && vote.VerifySig(c) == nil } +// laneProposalAcceptedByEpoch reports whether p verifies under ep's committee. +func laneProposalAcceptedByEpoch(ep *types.Epoch, p *types.Signed[*types.LaneProposal]) bool { + c := ep.Committee() + return p.Msg().Verify(c) == nil && p.VerifySig(c) == nil +} + +// laneAcceptedUnder reports whether accept holds for the applied epoch, or for +// the Anchor epoch when present and a different EpochIndex. +func laneAcceptedUnder(inner *inner, accept func(*types.Epoch) bool) bool { + applied := inner.epoch.Load() + if accept(applied) { + return true + } + ae, ok := inner.anchorEpoch.Get() + if !ok || ae.EpochIndex() == applied.EpochIndex() { + return false + } + return accept(ae) +} + // headers collects headers for the given range under ep (the CommitQC's road epoch). // Returns ErrPruned if the lane is closed under ep or the range is unavailable. // Does not wait for future lanes. @@ -457,11 +489,12 @@ func (s *State) headers(ctx context.Context, ep *types.Epoch, lr *types.LaneRang 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 + if bv, ok := q.q[n]; ok { + if h, ok := bv.header(want).Get(); ok { + want = h.ParentHash() + headers[len(headers)-i-1] = h + break + } } // Otherwise, wait. if err := ctrl.Wait(ctx); err != nil { @@ -524,7 +557,7 @@ func (s *State) WaitForLaneQCs( for lane := range ep.Committee().Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { - if qc, ok := inner.laneQC(lane, first+i); ok { + if qc, ok := inner.laneQC(lane, first+i).Get(); ok { laneQCs[lane] = qc } else { break @@ -632,11 +665,9 @@ func (s *State) runEvict(ctx context.Context) error { } for inner, ctrl := range s.inner.Lock() { if anchor.CommitQC.Index() >= inner.roads.first { - ep, err := anchorEpochOf(s.data.Registry(), anchor) - if err != nil { - return err - } - inner.prune(anchor, ep) + inner.prune(anchor) + // Mostly for catch-up: tip jumps to the Anchor when roads empty. + inner.refreshConsensusSpec() } ctrl.Updated() } @@ -644,9 +675,35 @@ func (s *State) runEvict(ctx context.Context) error { }) } +// runEpochAdvance is the sole writer of inner.epoch after construction. It waits +// for the execution leash on the registry, then seal and the prune leash on +// avail's inner watch (leashesMet), and installs one epoch per wake. +func (s *State) runEpochAdvance(ctx context.Context) error { + for { + next := s.epoch.Load().EpochIndex() + 1 + ep, err := s.data.Registry().WaitForEpoch(ctx, next) + if err != nil { + return err + } + for inner, ctrl := range s.inner.Lock() { + if err := ctrl.WaitUntil(ctx, func() bool { + return inner.leashesMet() + }); err != nil { + return err + } + if got := inner.epoch.Load().EpochIndex(); got+1 != next { + return fmt.Errorf("runEpochAdvance: applied %d, want %d before install", got, next-1) + } + inner.installEpoch(ep) + ctrl.Updated() + } + } +} + // Run runs the background tasks of the state. func (s *State) Run(ctx context.Context) error { return scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { + scope.SpawnNamed("runEpochAdvance", func() error { return s.runEpochAdvance(ctx) }) scope.SpawnNamed("runEvict", func() error { return s.runEvict(ctx) }) scope.SpawnNamed("runPersist", func() error { return s.runPersist(ctx) }) scope.SpawnNamed("runPushQC", func() error { return s.runPushQC(ctx) }) @@ -729,9 +786,12 @@ func (s *State) setNextBlockToPersist(lane types.LaneID, next types.BlockNumber) // markCommitQCsPersisted publishes the latest persisted CommitQC, // gating consensus from advancing until the QC is durable. +// ConsensusSpec is refreshed here so tip catch-up stays visible even while +// runEpochAdvance is parked on WaitForEpoch. func (s *State) markCommitQCsPersisted(qc *types.CommitQC) { for inner := range s.inner.Lock() { inner.persistedCommitQC.Store(utils.Some(qc)) + inner.refreshConsensusSpec() } } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index f4a0b9e388..72c5ce763a 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "testing" + "testing/synctest" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" @@ -36,6 +37,13 @@ func pushPeerLaneBlock(state *State, key types.SecretKey, payload *types.Payload return b, nil } +func nextRoad(s *State) types.RoadIndex { + for inner := range s.inner.Lock() { + return inner.roads.next + } + panic("unreachable") +} + type byLane[T any] map[types.LaneID][]T func makeAppVotes(keys []types.SecretKey, proposal *types.AppProposal) []*types.Signed[*types.AppVote] { @@ -116,62 +124,69 @@ func TestPrune_AnchorEpochDropsClosedLane(t *testing.T) { if err != nil { return fmt.Errorf("NewState: %w", err) } + sc.SpawnBgNamed("runEpochAdvance", func() error { return utils.IgnoreCancel(state.runEpochAdvance(ctx)) }) + lane0 := state.LocalLane().OrPanic("genesis") sub := state.SubscribeLaneProposals(lane0, 0) - ep1, err := registry.ActivateEpoch( + epLeave, err := registry.ActivateEpoch( map[types.PublicKey]uint64{b.Public(): 1}, - types.OpenRoadRange(), time.Time{}, registry.FirstBlock(), + time.Time{}, registry.FirstBlock(), ) if err != nil { return err } - state.ApplyEpoch(ep1) - - for inner := range state.inner.Lock() { - if _, ok := inner.blocks[lane0]; !ok { - return fmt.Errorf("ApplyEpoch must not drop closing-lane maps") - } + if epLeave.EpochIndex() != 2 { + return fmt.Errorf("leave epoch = %d, want 2 (epoch 1 is genesis-seeded)", epLeave.EpochIndex()) } - - qc1, blocks1 := data.TestCommitQC(rng, ep1, []types.SecretKey{b}, utils.Some(qc0.QC())) - if err := ds.PushQC(ctx, qc1, blocks1); err != nil { + // Data already holds an AppQC for epoch 0; NewState's prune stamped the + // Anchor. Advance the seal cursor without admitting LastRoad tips — runEvict + // is not running, and the rest of this test expects empty roads. + seekRoads(state, epoch.FirstRoad(1)) + if _, err := state.Epoch().Wait(ctx, func(ep *types.Epoch) bool { + return ep.EpochIndex() >= 1 + }); err != nil { return err } - appHash1 := types.GenAppHash(rng) - if err := ds.PushAppHash(ctx, qc1.QC().GlobalRange().Next-1, appHash1); err != nil { - return err + ep1, ok := registry.EpochByIndex(1) + if !ok { + return fmt.Errorf("epoch 1 missing") } - if err := ds.PushAppQC(ctx, data.TestAppQC([]types.SecretKey{b}, types.NewAppProposal(qc1.QC().Proposal(), appHash1))); err != nil { - return err + for inner, ctrl := range state.inner.Lock() { + inner.anchorEpoch = utils.Some(ep1) + ctrl.Updated() } - var anchor data.Anchor - if _, err := ds.Anchor().Wait(ctx, func(a utils.Option[data.Anchor]) bool { - got, ok := a.Get() - if ok && got.CommitQC.Index() == qc1.QC().Index() { - anchor = got - return true - } - return false + seekRoads(state, epoch.FirstRoad(2)) + if _, err := state.Epoch().Wait(ctx, func(ep *types.Epoch) bool { + return ep.EpochIndex() >= epLeave.EpochIndex() }); err != nil { return err } + for inner := range state.inner.Lock() { + if _, ok := inner.blocks[lane0]; !ok { + return fmt.Errorf("epoch advance must not drop closing-lane maps") + } + } + + // Construct a leave-epoch Anchor locally: data still holds only qc0, so a + // FirstRoad(2) CommitQC cannot be pushed without filling the road gap. + prev := tipLink(ep1, keys[0], epoch.LastRoad(1)) + qcLeave := types.BuildCommitQC(epLeave, []types.SecretKey{b}, utils.Some(prev), nil) + anchor := data.Anchor{ + CommitQC: qcLeave, + AppQC: data.TestAppQC([]types.SecretKey{b}, types.NewAppProposal(qcLeave.Proposal(), types.AppHash{})), + Epoch: epLeave, + } + for inner, ctrl := range state.inner.Lock() { if inner.roads.first < inner.roads.next { - return fmt.Errorf("roads should be empty after tip prune") + return fmt.Errorf("roads should still be empty (seekRoads, no runEvict)") } if _, ok := inner.blocks[lane0]; !ok { return fmt.Errorf("closing lane maps should still be present before anchor prune") } - ep, err := anchorEpochOf(registry, anchor) - if err != nil { - return fmt.Errorf("anchorEpochOf: %w", err) - } - if ep.EpochIndex() != ep1.EpochIndex() { - return fmt.Errorf("anchorEpochOf: got epoch %d, want %d", ep.EpochIndex(), ep1.EpochIndex()) - } - n := inner.prune(anchor, ep) + n := inner.prune(anchor) if n != 1 { return fmt.Errorf("prune dropped %d lanes, want 1", n) } @@ -618,3 +633,389 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, cp.Close()) }) } + +// TestHeaders_WaitsForPrevEpochLaneVote checks that after the applied epoch +// advances, a LaneVote that only verifies under the Anchor (prev) committee is +// still ingested into byKey and unblocks headers() for a prior-epoch LaneRange. +func TestHeaders_WaitsForPrevEpochLaneVote(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + stay := types.GenSecretKey(rng) + leaver := types.GenSecretKey(rng) + a := types.GenSecretKey(rng) + b := types.GenSecretKey(rng) + + genesis, err := types.NewCommittee(map[types.PublicKey]uint64{ + stay.Public(): 1, leaver.Public(): 1, a.Public(): 1, b.Public(): 1, + }) + require.NoError(t, err) + registry, err := epoch.NewRegistry(genesis, 0, time.Time{}) + require.NoError(t, err) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(stay, ds, utils.None[string]()) + require.NoError(t, err) + + require.NoError(t, scope.Run(ctx, func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("runEpochAdvance", func() error { + return utils.IgnoreCancel(state.runEpochAdvance(ctx)) + }) + + ep0 := registry.LatestEpoch() + lane := ep0.Committee().Lane(stay.Public()).OrPanic("stay lane") + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() + leaverVote := types.Sign(leaver, types.NewLaneVote(header)) + + epLeave, err := registry.ActivateEpoch( + map[types.PublicKey]uint64{stay.Public(): 1, a.Public(): 1, b.Public(): 1}, + time.Time{}, registry.FirstBlock(), + ) + if err != nil { + return err + } + keys := []types.SecretKey{stay, leaver, a, b} + if err := DriveAdvance(ctx, state, keys, epLeave.EpochIndex()); err != nil { + return err + } + + for inner := range state.inner.Lock() { + if inner.epoch.Load().EpochIndex() != epLeave.EpochIndex() { + return fmt.Errorf("applied epoch = %d, want %d", inner.epoch.Load().EpochIndex(), epLeave.EpochIndex()) + } + ae, ok := inner.anchorEpoch.Get() + if !ok { + return fmt.Errorf("anchor epoch missing") + } + if ae.EpochIndex() >= epLeave.EpochIndex() { + return fmt.Errorf("anchor epoch = %d, want < %d", ae.EpochIndex(), epLeave.EpochIndex()) + } + } + if laneVoteAcceptedByEpoch(epLeave, leaverVote) { + return fmt.Errorf("leaver must fail under applied") + } + if !laneVoteAcceptedByEpoch(ep0, leaverVote) { + return fmt.Errorf("leaver must pass under anchor") + } + + lr := types.NewLaneRange(lane, 0, utils.Some(header)) + var got []*types.BlockHeader + var herr error + done := false + sc.Spawn(func() error { + got, herr = state.headers(ctx, ep0, lr) + done = true + return nil + }) + synctest.Wait() + if done { + return fmt.Errorf("headers should wait for a matching LaneVote") + } + + if err := state.PushVote(ctx, leaverVote); err != nil { + return err + } + synctest.Wait() + if !done { + return fmt.Errorf("headers did not complete after LaneVote") + } + if herr != nil { + return herr + } + if len(got) != 1 { + return fmt.Errorf("headers len = %d, want 1", len(got)) + } + if got[0].Hash() != header.Hash() { + return fmt.Errorf("header hash mismatch") + } + return nil + })) + }) +} + +func TestPushCommitQC_MidEpochNoWait(t *testing.T) { + f := newSealFixture(t) + _, err := f.registry.EpochAt(epoch.FirstRoad(f.m + 1)) + require.Error(t, err) + + seekRoads(f.state, epoch.FirstRoad(f.m)) + epPrev, ok := f.registry.EpochByIndex(f.m - 1) + require.True(t, ok) + prev := tipLink(epPrev, f.keys[0], epoch.LastRoad(f.m-1)) + qc := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) + require.Equal(t, epoch.FirstRoad(f.m), qc.Proposal().Index()) + + require.NoError(t, f.state.PushCommitQC(t.Context(), qc)) + require.Equal(t, epoch.FirstRoad(f.m)+1, nextRoad(f.state)) +} + +func TestPushCommitQC_FutureEpochParksUntilAdvance(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + f := newSealFixture(t) + + prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) + qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) + require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) + setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) + + f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) + ep2, ok := f.registry.EpochByIndex(f.m + 1) + require.True(t, ok) + qcNext := types.BuildCommitQC(ep2, f.keys, utils.Some(qcLast), nil) + require.Equal(t, epoch.FirstRoad(f.m+1), qcNext.Proposal().Index()) + + var pushErr error + go func() { pushErr = f.state.PushCommitQC(ctx, qcNext) }() + synctest.Wait() + require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex()) + require.Equal(t, epoch.LastRoad(f.m)+1, nextRoad(f.state), "future QC must stay parked") + + var runErr error + go func() { runErr = f.state.runEpochAdvance(ctx) }() + _, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= f.m+1 + }) + require.NoError(t, err) + synctest.Wait() + require.NoError(t, pushErr) + require.Equal(t, epoch.FirstRoad(f.m+1)+1, nextRoad(f.state)) + cancel() + synctest.Wait() + require.ErrorIs(t, runErr, context.Canceled) + }) +} + +func TestPushCommitQC_StaleAfterAdvanceSoftDrops(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok) + require.NoError(t, scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("runEpochAdvance", func() error { + return utils.IgnoreCancel(state.runEpochAdvance(ctx)) + }) + return DriveAdvance(ctx, state, keys, ep1.EpochIndex()) + })) + + ep0, ok := registry.EpochByIndex(0) + require.True(t, ok) + before := nextRoad(state) + qc0 := types.BuildCommitQC(ep0, keys, utils.None[*types.CommitQC](), nil) + require.Equal(t, types.EpochIndex(0), qc0.Proposal().EpochIndex()) + require.NoError(t, state.PushCommitQC(t.Context(), qc0)) + require.Equal(t, before, nextRoad(state)) +} + +type sealFixture struct { + registry *epoch.Registry + keys []types.SecretKey + state *State + ep *types.Epoch + m types.EpochIndex +} + +func newSealFixture(t *testing.T) *sealFixture { + t.Helper() + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + const m types.EpochIndex = 1 + ep, ok := registry.EpochByIndex(m) + require.True(t, ok, "epoch 1 is present from NewRegistry") + _, err = registry.EpochAt(epoch.FirstRoad(m + 1)) + require.Error(t, err, "epoch 2 must be absent for exec-leash tests") + + require.NoError(t, scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("runEpochAdvance", func() error { + return utils.IgnoreCancel(state.runEpochAdvance(ctx)) + }) + return DriveAdvance(ctx, state, keys, m) + })) + seekRoads(state, epoch.LastRoad(m)) + return &sealFixture{registry: registry, keys: keys, state: state, ep: ep, m: m} +} + +func TestWaitUntilApplied_ParksUntilEpochAdvance(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 2) + ds := newTestDataState(&data.Config{Registry: registry}) + state, err := NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok, "epoch 1 is present from NewRegistry") + + require.NoError(t, scope.Run(ctx, func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("runEpochAdvance", func() error { + return utils.IgnoreCancel(state.runEpochAdvance(ctx)) + }) + + var got *types.Epoch + var waitErr error + sc.Spawn(func() error { + got, waitErr = state.waitUntilApplied(ctx, 1) + return nil + }) + synctest.Wait() + if got != nil { + return fmt.Errorf("waitUntilApplied returned before epoch advance") + } + + if err := DriveAdvance(ctx, state, keys, ep1.EpochIndex()); err != nil { + return err + } + synctest.Wait() + if waitErr != nil { + return waitErr + } + if got.EpochIndex() != 1 { + return fmt.Errorf("waitUntilApplied epoch = %d, want 1", got.EpochIndex()) + } + return nil + })) + }) +} + +func TestRunEpochAdvance_AdvancesWhenBothLeashesMet(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + f := newSealFixture(t) + f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) + + prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) + qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) + require.Equal(t, epoch.LastRoad(f.m), qcLast.Proposal().Index()) + require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) + setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) + require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex()) + + var runErr error + go func() { runErr = f.state.runEpochAdvance(ctx) }() + + ep, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= f.m+1 + }) + require.NoError(t, err) + require.Equal(t, f.m+1, ep.EpochIndex()) + require.Equal(t, f.m+1, f.state.Epoch().Load().EpochIndex()) + + cancel() + synctest.Wait() + require.ErrorIs(t, runErr, context.Canceled) + }) +} + +func TestRunEpochAdvance_WaitsForRegistry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + f := newSealFixture(t) + + prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) + qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) + require.Equal(t, epoch.LastRoad(f.m), qcLast.Proposal().Index()) + require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) + setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) + + var runErr error + go func() { runErr = f.state.runEpochAdvance(ctx) }() + synctest.Wait() + require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex(), "parked on WaitForEpoch(M+1)") + + f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) + _, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= f.m+1 + }) + require.NoError(t, err) + cancel() + synctest.Wait() + require.ErrorIs(t, runErr, context.Canceled) + }) +} + +// A durable-tip catch-up refreshes ConsensusSpec at the persist write site, +// even while epoch advance is parked on WaitForEpoch. +func TestMarkCommitQCsPersisted_RefreshesSpecWhileEpochAdvanceWaitsForRegistry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + f := newSealFixture(t) + + last := epoch.LastRoad(f.m) + seekRoads(f.state, last-2) + prev := tipLink(f.ep, f.keys[0], last-3) + qcA := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) + qcB := types.BuildCommitQC(f.ep, f.keys, utils.Some(qcA), nil) + qcC := types.BuildCommitQC(f.ep, f.keys, utils.Some(qcB), nil) + require.Equal(t, last-2, qcA.Index()) + require.Equal(t, last-1, qcB.Index()) + require.Equal(t, last, qcC.Index()) + require.NoError(t, f.state.PushCommitQC(ctx, qcA)) + require.NoError(t, f.state.PushCommitQC(ctx, qcB)) + require.NoError(t, f.state.PushCommitQC(ctx, qcC)) + setRoadAppQC(f.state, qcC.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcC.Proposal(), types.GenAppHash(rng)))) + f.state.markCommitQCsPersisted(qcA) + + spec := f.state.SubscribeConsensusSpec() + var advanceErr error + go func() { advanceErr = f.state.runEpochAdvance(ctx) }() + synctest.Wait() + require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex(), "parked on WaitForEpoch(M+1)") + got, ok := spec.Load().Get() + require.True(t, ok) + require.Equal(t, qcA.Index(), got.CommitQC.Index()) + + f.state.markCommitQCsPersisted(qcB) + got, ok = spec.Load().Get() + require.True(t, ok) + require.Equal(t, qcB.Index(), got.CommitQC.Index()) + require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex(), "still waiting on registry") + + cancel() + synctest.Wait() + require.ErrorIs(t, advanceErr, context.Canceled) + }) +} + +func TestRunEpochAdvance_WaitsForAppQC(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + f := newSealFixture(t) + f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) + + prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) + qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) + require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) + + var runErr error + go func() { runErr = f.state.runEpochAdvance(ctx) }() + synctest.Wait() + require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex(), "parked without AppQC covering M") + + setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) + _, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= f.m+1 + }) + require.NoError(t, err) + cancel() + synctest.Wait() + require.ErrorIs(t, runErr, context.Canceled) + }) +} diff --git a/sei-tendermint/internal/autobahn/avail/subscriptions_test.go b/sei-tendermint/internal/autobahn/avail/subscriptions_test.go index c5ecfc2902..f9afde2385 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions_test.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "testing" + "testing/synctest" "time" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/memblock" @@ -15,39 +16,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) -func TestSubscribeLaneProposals_ErrLaneClosedAfterMapDrop(t *testing.T) { - rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 2) - a, b := keys[0], keys[1] - db := memblock.NewBlockDB() - t.Cleanup(func() { require.NoError(t, db.Close()) }) - ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, db)) - state := utils.OrPanic1(NewState(a, ds, utils.None[string]())) - - lane0 := state.LocalLane().OrPanic("genesis") - want, err := state.ProduceLocalBlock(lane0, 0, types.GenPayload(rng)) - require.NoError(t, err) - sub := state.SubscribeLaneProposals(lane0, 0) - - ep, err := registry.ActivateEpoch( - map[types.PublicKey]uint64{b.Public(): 1}, - types.OpenRoadRange(), time.Time{}, registry.FirstBlock(), - ) - require.NoError(t, err) - state.ApplyEpoch(ep) - - got, err := sub.Recv(t.Context()) - require.NoError(t, err) - require.Equal(t, want.Msg().Block().Header().Hash(), got.Msg().Block().Header().Hash()) - - for inner, ctrl := range state.inner.Lock() { - inner.dropLanes([]types.LaneID{lane0}) - ctrl.Updated() - } - _, err = sub.Recv(t.Context()) - require.ErrorIs(t, err, ErrLaneClosed) -} - func TestSubscribeLaneProposals_WrongValidatorPanics(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 2) @@ -88,22 +56,21 @@ func TestSubscribeLaneProposals_StayLeaveRejoin(t *testing.T) { require.NoError(t, err) require.Equal(t, want0.Msg().Block().Header().Hash(), got0.Msg().Block().Header().Hash()) - // Stay while Recv waits for the next block: ApplyEpoch must not end the subscribe. + // Stay: epoch 1 is already seeded at genesis with the same committee; advance + // into it without ActivateEpoch (which must not rewrite existing epochs). var want1, got1 *types.Signed[*types.LaneProposal] require.NoError(t, scope.Run(ctx, func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("runEpochAdvance", func() error { + return utils.IgnoreCancel(state.runEpochAdvance(ctx)) + }) sc.Spawn(func() error { var err error got1, err = sub.Recv(ctx) return err }) - epStay, err := registry.ActivateEpoch( - map[types.PublicKey]uint64{a.Public(): 1, b.Public(): 1}, - types.OpenRoadRange(), time.Time{}, registry.FirstBlock(), - ) - if err != nil { + if err := DriveAdvance(ctx, state, keys, 1); err != nil { return err } - state.ApplyEpoch(epStay) if cur := state.LocalLane().OrPanic("stay"); cur != lane0 { return fmt.Errorf("stay LocalLane = %v, want %v", cur, lane0) } @@ -116,17 +83,34 @@ func TestSubscribeLaneProposals_StayLeaveRejoin(t *testing.T) { require.NoError(t, err) require.Equal(t, lane0, got) - // Leave: peer drops from committee; map drop ends the subscribe. + // Leave: peer drops from committee at epoch 2 (first vacant after genesis seeds). + // Anchor-epoch prune drops closed lane maps (same path as runEvict) and ends the subscribe. epLeave, err := registry.ActivateEpoch( map[types.PublicKey]uint64{b.Public(): 1}, - types.OpenRoadRange(), time.Time{}, registry.FirstBlock(), + time.Time{}, registry.FirstBlock(), ) require.NoError(t, err) - state.ApplyEpoch(epLeave) + require.Equal(t, types.EpochIndex(2), epLeave.EpochIndex()) + require.NoError(t, scope.Run(ctx, func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("runEpochAdvance", func() error { + return utils.IgnoreCancel(state.runEpochAdvance(ctx)) + }) + return DriveAdvance(ctx, state, keys, epLeave.EpochIndex()) + })) require.False(t, state.LocalLane().IsPresent()) for inner, ctrl := range state.inner.Lock() { - inner.dropLanes([]types.LaneID{lane0}) + require.Greater(t, inner.roads.next, inner.roads.first) + tip := inner.roads.q[inner.roads.next-1].commitQC + ep := inner.epoch.Load() + require.Equal(t, epLeave.EpochIndex(), ep.EpochIndex()) + require.True(t, ep.IsClosed(lane0)) + n := inner.prune(data.Anchor{ + CommitQC: tip, + AppQC: data.TestAppQC([]types.SecretKey{b}, types.NewAppProposal(tip.Proposal(), types.AppHash{})), + Epoch: ep, + }) + require.Equal(t, 1, n) ctrl.Updated() } _, err = sub.Recv(ctx) @@ -135,6 +119,9 @@ func TestSubscribeLaneProposals_StayLeaveRejoin(t *testing.T) { // Rejoin: WaitForNextLane skips closed lane0 and observes the new LaneID. var gotLane types.LaneID require.NoError(t, scope.Run(ctx, func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("runEpochAdvance", func() error { + return utils.IgnoreCancel(state.runEpochAdvance(ctx)) + }) sc.Spawn(func() error { lane, err := state.WaitForNextLane(ctx, peer, utils.Some(lane0)) if err != nil { @@ -145,13 +132,12 @@ func TestSubscribeLaneProposals_StayLeaveRejoin(t *testing.T) { }) epJoin, err := registry.ActivateEpoch( map[types.PublicKey]uint64{a.Public(): 1, b.Public(): 1}, - types.OpenRoadRange(), time.Time{}, registry.FirstBlock(), + time.Time{}, registry.FirstBlock(), ) if err != nil { return err } - state.ApplyEpoch(epJoin) - return nil + return DriveAdvance(ctx, state, keys, epJoin.EpochIndex()) })) lane1 := state.LocalLane().OrPanic("rejoin") require.NotEqual(t, lane0, lane1) @@ -170,3 +156,92 @@ func TestSubscribeLaneProposals_StayLeaveRejoin(t *testing.T) { require.Equal(t, want.Msg().Block().Header().Hash(), gotBlk.Msg().Block().Header().Hash()) require.Equal(t, lane1, gotBlk.Msg().Block().Header().Lane()) } + +// Joiner catchup: a first-time joiner votes retained outstanding blocks; after +// leave/rejoin the cursor stays tip-aligned (no re-vote of already emitted +// headers; blocks produced while out are still voted once). +func TestJoinerCatchup_LaneVotes(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 1) + a := keys[0] + b := types.GenSecretKey(rng) + allKeys := append(keys, b) + + db := memblock.NewBlockDB() + t.Cleanup(func() { require.NoError(t, db.Close()) }) + ds := utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, db)) + stateA := utils.OrPanic1(NewState(a, ds, utils.None[string]())) + stateB := utils.OrPanic1(NewState(b, ds, utils.None[string]())) + laneA := stateA.LocalLane().OrPanic("genesis") + + activate := func(weights map[types.PublicKey]uint64) *types.Epoch { + ep, err := registry.ActivateEpoch(weights, time.Time{}, registry.FirstBlock()) + require.NoError(t, err) + return ep + } + advance := func(want types.EpochIndex) { + require.NoError(t, scope.Run(ctx, func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("runEpochAdvance", func() error { + return utils.IgnoreCancel(stateB.runEpochAdvance(ctx)) + }) + return DriveAdvance(ctx, stateB, allKeys, want) + })) + } + produce := func(n types.BlockNumber) *types.Signed[*types.LaneProposal] { + prop, err := stateA.ProduceLocalBlock(laneA, n, types.GenPayload(rng)) + require.NoError(t, err) + require.NoError(t, stateB.PushBlock(ctx, prop)) + stateB.setNextBlockToPersist(laneA, n+1) + return prop + } + both := map[types.PublicKey]uint64{a.Public(): 1, b.Public(): 1} + onlyA := map[types.PublicKey]uint64{a.Public(): 1} + + block0 := produce(0) + epJoin := activate(both) + advance(epJoin.EpochIndex()) + require.Equal(t, types.EpochIndex(2), stateB.LocalLane().OrPanic("joiner").Joined) + + sub := stateB.SubscribeLaneVotes() + batch, err := sub.RecvBatch(ctx) + require.NoError(t, err) + require.Equal(t, 1, len(batch)) + require.Equal(t, block0.Msg().Block().Header().Hash(), batch[0].Msg().Header().Hash()) + require.Equal(t, b.Public(), batch[0].Key()) + + epLeave := activate(onlyA) + advance(epLeave.EpochIndex()) + block1 := produce(1) // while out; skip RecvBatch so the cursor stays behind block1 + + epRejoin := activate(both) + advance(epRejoin.EpochIndex()) + require.Equal(t, types.EpochIndex(4), stateB.LocalLane().OrPanic("rejoiner").Joined) + + batch, err = sub.RecvBatch(ctx) + require.NoError(t, err) + require.Equal(t, 1, len(batch)) + require.Equal(t, block1.Msg().Block().Header().Hash(), batch[0].Msg().Header().Hash(), + "missed-while-out block is voted once; block0 must not be re-emitted") + + ctx2, cancel := context.WithCancel(ctx) + defer cancel() + done := false + go func() { + _, _ = sub.RecvBatch(ctx2) + done = true + }() + synctest.Wait() + require.False(t, done, "cursor must not rewind onto already-emitted headers") + cancel() + synctest.Wait() + require.True(t, done) + + block2 := produce(2) + batch, err = sub.RecvBatch(ctx) + require.NoError(t, err) + require.Equal(t, 1, len(batch)) + require.Equal(t, block2.Msg().Block().Header().Hash(), batch[0].Msg().Header().Hash()) + }) +} diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index 5e9bd3df76..d946336954 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -3,8 +3,11 @@ package avail import ( "context" "errors" + "fmt" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) @@ -76,3 +79,75 @@ func RunTestNetwork(ctx context.Context, states []*State) error { return nil }) } + +func seekRoads(s *State, idx types.RoadIndex) { + for inner, ctrl := range s.inner.Lock() { + inner.roads.first = idx + inner.roads.next = idx + ctrl.Updated() + } +} + +func setRoadAppQC(s *State, idx types.RoadIndex, appQC *types.AppQC) { + for inner, ctrl := range s.inner.Lock() { + r := inner.roads.q[idx] + r.appQC = utils.Some(appQC) + // Tests inject road AppQCs without going through data's persist/Anchor + // pipeline; stamp the Anchor watermark to the road's epoch so the prune + // leash sees the same coverage production would after one flush. + inner.anchorEpoch = utils.Some(r.epoch) + ctrl.Updated() + } +} + +func tipLink(ep *types.Epoch, key types.SecretKey, idx types.RoadIndex) *types.CommitQC { + return types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(key, types.NewCommitVote(types.ProposalAt(ep, types.View{Index: idx, Number: 0}))), + }) +} + +// DriveAdvance seals each applied epoch through want-1 and waits for +// runEpochAdvance to install want. The registry must already contain want; +// runEpochAdvance must be running. +// Intended for tests only. +func DriveAdvance(ctx context.Context, state *State, keys []types.SecretKey, want types.EpochIndex) error { + for state.Epoch().Load().EpochIndex() < want { + cur := state.Epoch().Load() + last := cur.RoadRange().Next - 1 + if cur.RoadRange().Next == utils.Max[types.RoadIndex]() { + return fmt.Errorf("DriveAdvance: cannot seal open road range at epoch %d", cur.EpochIndex()) + } + cks := make([]types.SecretKey, 0, len(keys)) + for _, k := range keys { + if cur.Committee().HasReplica(k.Public()) { + cks = append(cks, k) + } + } + if len(cks) == 0 { + return fmt.Errorf("DriveAdvance: no committee keys for epoch %d", cur.EpochIndex()) + } + seekRoads(state, last) + var qc *types.CommitQC + if last == 0 { + qc = types.BuildCommitQC(cur, cks, utils.None[*types.CommitQC](), nil) + } else { + qc = types.BuildCommitQC(cur, cks, utils.Some(tipLink(cur, cks[0], last-1)), nil) + } + if qc.Index() != last { + return fmt.Errorf("DriveAdvance: qc index %d != last %d", qc.Index(), last) + } + if err := state.PushCommitQC(ctx, qc); err != nil { + return err + } + setRoadAppQC(state, qc.Index(), data.TestAppQC(cks, types.NewAppProposal(qc.Proposal(), types.AppHash{}))) + if _, err := state.Epoch().Wait(ctx, func(ep *types.Epoch) bool { + return ep.EpochIndex() > cur.EpochIndex() + }); err != nil { + return err + } + } + if got := state.Epoch().Load().EpochIndex(); got < want { + return fmt.Errorf("DriveAdvance: epoch %d < want %d", got, want) + } + return nil +} diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index e551613386..36dec461c1 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -103,13 +103,18 @@ func (i inner) View() types.View { 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). -func newInner(data utils.Option[*pb.PersistedInner], registry *epoch.Registry) (inner, error) { +// newInner restores consensus state from avail's ConsensusSpec. The tip CommitQC +// and next-view epoch always come from the spec. The WAL is kept only for +// same-view votes / TimeoutQC / PrepareQC when its tip matches the spec. +// specOpt is None at genesis (no durable tip yet). Returns +// ErrAvailBehindConsensus when the WAL tip is ahead of the spec. +func newInner( + loaded utils.Option[*pb.PersistedInner], + specOpt utils.Option[types.ConsensusSpec], + registry *epoch.Registry, +) (inner, error) { var persisted persistedInner - - if p, ok := data.Get(); ok { + if p, ok := loaded.Get(); ok { decoded, err := innerProtoConv.Decode(p) if err != nil { return inner{}, fmt.Errorf("corrupt persisted state: %w", err) @@ -117,35 +122,58 @@ 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 { - return inner{}, err + persistedViewIdx := types.NextIndexOpt(persisted.CommitQC) + spec, hasSpec := specOpt.Get() + specViewIdx := types.RoadIndex(0) + if hasSpec { + specViewIdx = spec.CommitQC.Index() + 1 + } + if persistedViewIdx > specViewIdx { + return inner{}, fmt.Errorf("%w: persisted tip %d > ConsensusSpec tip %d", + ErrAvailBehindConsensus, persistedViewIdx, specViewIdx) + } + + if !hasSpec { // genesis: no ConsensusSpec (and thus no WAL CommitQC) + ep, ok := registry.EpochByIndex(0) + if !ok { + panic("genesis epoch 0 not registered") + } + if err := persisted.validate(ep); err != nil { + return inner{}, err + } + logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) + return inner{persistedInner: persisted, epoch: ep}, nil } - logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) + if specViewIdx == persistedViewIdx { + // Same tip: take CommitQC from the spec; keep WAL votes / view QCs. + out := persisted + out.CommitQC = utils.Some(spec.CommitQC) + if err := out.validate(spec.Epoch); err != nil { + return inner{}, err + } + logger.Info("restored consensus state", "state", innerProtoConv.Encode(&out)) + return inner{persistedInner: out, epoch: spec.Epoch}, nil + } - return inner{persistedInner: persisted, epoch: ep}, nil + out := persistedInner{CommitQC: utils.Some(spec.CommitQC)} + logger.Info("restored consensus state from avail ConsensusSpec", "state", innerProtoConv.Encode(&out)) + return inner{persistedInner: out, epoch: spec.Epoch}, nil } -func (s *State) pushCommitQC(qc *types.CommitQC) error { - i := s.innerRecv.Load() - if qc.Proposal().Index() < i.View().Index { +// pushSpecFromAvail installs avail's ConsensusSpec tip and clears per-view state. +func (s *State) pushSpecFromAvail(spec types.ConsensusSpec) error { + qc := spec.CommitQC + if qc.Proposal().Index() < s.innerRecv.Load().View().Index { return nil } - if err := qc.Verify(i.epoch); err != nil { - return fmt.Errorf("qc.Verify(): %w", err) - } for iSend := range s.inner.Lock() { i := iSend.Load() if qc.Proposal().Index() < i.View().Index { 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}) + iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(spec.CommitQC)}, epoch: spec.Epoch}) } return nil } diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 93d13f84ac..6edc787f3d 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -3,6 +3,7 @@ package consensus import ( "context" "errors" + "fmt" "path/filepath" "testing" "time" @@ -10,6 +11,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/memblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "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" @@ -17,6 +19,7 @@ import ( "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/require" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) func newTestBlockDB(t *testing.T, dir string) types.BlockDB { @@ -31,6 +34,15 @@ func newTestDataState(registry *epoch.Registry) *data.State { return utils.OrPanic1(data.NewState(&data.Config{Registry: registry}, memblock.NewBlockDB())) } +func newTestAvail(t *testing.T, registry *epoch.Registry, key types.SecretKey) (*data.State, *avail.State) { + t.Helper() + ds := newTestDataState(registry) + av, err := avail.NewState(key, ds, utils.None[string]()) + require.NoError(t, err) + t.Cleanup(func() { _ = av.Close() }) + return ds, av +} + // seedPersistedInner is a test helper that persists a persistedInner using the public API. func seedPersistedInner(dir string, state *persistedInner) { p, _, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) @@ -43,13 +55,66 @@ func seedPersistedInner(dir string, state *persistedInner) { } // loadInner is a test helper that loads persisted data and creates inner. -// Mirrors what NewState does: NewPersister → newInner. -func loadInner(dir string, registry *epoch.Registry) (inner, error) { - _, data, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) +// Mirrors what NewState does: avail first (aligned to the WAL tip via PushCommitQC), +// then newInner. +func loadInner(t *testing.T, dir string, registry *epoch.Registry, keys []types.SecretKey) (inner, error) { + t.Helper() + _, persisted, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) if err != nil { return inner{}, err } - return newInner(data, registry) + _, av := newTestAvail(t, registry, keys[0]) + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + go func() { _ = utils.IgnoreCancel(av.Run(ctx)) }() + + if p, ok := persisted.Get(); ok { + decoded, err := innerProtoConv.Decode(p) + if err != nil { + return inner{}, err + } + if cqc, ok := decoded.CommitQC.Get(); ok { + if err := alignAvailToTip(ctx, t, av, registry, keys, cqc); err != nil { + return inner{}, err + } + } + } + return newInner(persisted, av.SubscribeConsensusSpec().Load(), registry) +} + +// alignAvailToTip pushes CommitQCs 0..tip.Index() through avail and waits until +// the tip index is durable. The QCs are freshly built for the registry — the tip +// CommitQC used at restore comes from ConsensusSpec, not the WAL bytes. +// Callers must keep tip.Index() small — EpochLength boundaries are not replayed +// in unit tests. +func alignAvailToTip( + ctx context.Context, + t *testing.T, + av *avail.State, + registry *epoch.Registry, + keys []types.SecretKey, + tip *types.CommitQC, +) error { + t.Helper() + require.LessOrEqual(t, tip.Index(), types.RoadIndex(64), "alignAvailToTip: tip too high for unit replay") + + var prev utils.Option[*types.CommitQC] + for idx := types.RoadIndex(0); idx <= tip.Index(); idx++ { + ep, err := registry.EpochAt(idx) + if err != nil { + return err + } + qc := types.BuildCommitQC(ep, keys, prev, nil) + if err := av.PushCommitQC(ctx, qc); err != nil { + return err + } + prev = utils.Some(qc) + } + _, err := av.LastCommitQC().Wait(ctx, func(o utils.Option[*types.CommitQC]) bool { + c, ok := o.Get() + return ok && c.Index() >= tip.Index() + }) + return err } // makePrepareQC creates a PrepareQC with valid signatures from the given keys. @@ -63,13 +128,149 @@ func makePrepareQC(keys []types.SecretKey, proposal *types.Proposal) *types.Prep func TestNewInnerEmpty(t *testing.T) { rng := utils.TestRng() - registry, _ := epoch.GenRegistry(rng, 1) - // No data should return empty inner (persistence disabled / fresh start) - i, err := newInner(utils.None[*pb.PersistedInner](), registry) + registry, keys := epoch.GenRegistry(rng, 1) + _, av := newTestAvail(t, registry, keys[0]) + i, err := newInner(utils.None[*pb.PersistedInner](), av.SubscribeConsensusSpec().Load(), registry) require.NoError(t, err) require.False(t, i.PrepareVote.IsPresent(), "prepareVote should be None") require.False(t, i.CommitVote.IsPresent(), "commitVote should be None") require.False(t, i.TimeoutVote.IsPresent(), "timeoutVote should be None") + require.Equal(t, types.EpochIndex(0), i.epoch.EpochIndex()) +} + +// TestNewInner_RejectsWALAheadOfSpec: after avail catch-up, ConsensusSpec must +// cover the WAL tip. A WAL tip ahead of the spec is a failed catch-up. +func TestNewInner_RejectsWALAheadOfSpec(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + + ep0, ok := registry.EpochByIndex(0) + require.True(t, ok) + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok) + + last := epoch.LastRoad(0) + prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}))), + }) + qcLast := types.BuildCommitQC(ep0, keys, utils.Some(prev), nil) + require.Equal(t, last, qcLast.Index()) + + // Spec still withheld (None): next-view epoch not applied after a floor. + view := types.View{Index: last + 1, Number: 0, EpochIndex: 1} + proposal := types.GenProposalForEpoch(rng, ep1, view) + vote := types.Sign(keys[0], types.NewPrepareVote(proposal)) + persisted := persistedInner{ + CommitQC: utils.Some(qcLast), + PrepareVote: utils.Some(vote), + } + + _, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), utils.None[types.ConsensusSpec](), registry) + require.ErrorIs(t, err, ErrAvailBehindConsensus) +} + +// TestNewInner_EqualTipKeepsVotes: matching tips take CommitQC from the spec and +// retain WAL votes for anti-equivocation. +func TestNewInner_EqualTipKeepsVotes(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + + ep0, ok := registry.EpochByIndex(0) + require.True(t, ok) + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok) + + last := epoch.LastRoad(0) + prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}))), + }) + qcLast := types.BuildCommitQC(ep0, keys, utils.Some(prev), nil) + + view := types.View{Index: last + 1, Number: 0, EpochIndex: 1} + proposal := types.GenProposalForEpoch(rng, ep1, view) + vote := types.Sign(keys[0], types.NewPrepareVote(proposal)) + persisted := persistedInner{ + CommitQC: utils.Some(qcLast), + PrepareVote: utils.Some(vote), + } + spec := types.ConsensusSpec{CommitQC: qcLast, Epoch: ep1} + + i, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), utils.Some(spec), registry) + require.NoError(t, err) + require.Equal(t, last+1, i.View().Index) + require.Equal(t, types.EpochIndex(1), i.epoch.EpochIndex()) + got, ok := i.PrepareVote.Get() + require.True(t, ok) + require.Equal(t, view, got.Msg().Proposal().View()) +} + +// TestRestore_BoundaryCatchUpSpecCoversWAL is the restart invariant blind-Spec +// trust depends on. After avail catch-up installs epoch 1 at the LastRoad(0) +// tip, ConsensusSpec must republish that tip so a WAL at the same tip restores +// without ErrAvailBehindConsensus and keeps anti-equivocation votes. +func TestRestore_BoundaryCatchUpSpecCoversWAL(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + registry.AdvanceIfNeeded(epoch.LastRoad(0)) + + ds := newTestDataState(registry) + av, err := avail.NewState(keys[0], ds, utils.None[string]()) + require.NoError(t, err) + t.Cleanup(func() { _ = av.Close() }) + + last := epoch.LastRoad(0) + var spec types.ConsensusSpec + require.NoError(t, scope.Run(t.Context(), 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(av.Run(ctx)) + }) + if err := avail.DriveAdvance(ctx, av, keys, 1); err != nil { + return fmt.Errorf("DriveAdvance: %w", err) + } + if _, err := av.LastCommitQC().Wait(ctx, func(o utils.Option[*types.CommitQC]) bool { + c, ok := o.Get() + return ok && c.Index() >= last + }); err != nil { + return fmt.Errorf("wait durable tip: %w", err) + } + got, err := av.SubscribeConsensusSpec().Wait(ctx, func(o utils.Option[types.ConsensusSpec]) bool { + sp, ok := o.Get() + return ok && sp.CommitQC.Index() >= last && sp.Epoch.EpochIndex() >= 1 + }) + if err != nil { + return fmt.Errorf("wait ConsensusSpec: %w", err) + } + sp, ok := got.Get() + if !ok { + return fmt.Errorf("ConsensusSpec missing after catch-up") + } + spec = sp + return nil + })) + + require.Equal(t, last, spec.CommitQC.Index(), "catch-up must republish the boundary tip, not withhold") + require.Equal(t, types.EpochIndex(1), spec.Epoch.EpochIndex()) + + view := types.View{Index: last + 1, Number: 0, EpochIndex: 1} + proposal := types.GenProposalForEpoch(rng, spec.Epoch, view) + vote := types.Sign(keys[0], types.NewPrepareVote(proposal)) + persisted := persistedInner{ + CommitQC: utils.Some(spec.CommitQC), + PrepareVote: utils.Some(vote), + } + + i, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), utils.Some(spec), registry) + require.NoError(t, err) + require.Equal(t, last+1, i.View().Index) + require.Equal(t, types.EpochIndex(1), i.epoch.EpochIndex()) + got, ok := i.PrepareVote.Get() + require.True(t, ok, "equal-tip restore must keep anti-equivocation vote") + require.Equal(t, view, got.Msg().Proposal().View()) } func TestNewInnerPrepareVote(t *testing.T) { @@ -87,7 +288,7 @@ func TestNewInnerPrepareVote(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) loaded, ok := i.PrepareVote.Get() require.True(t, ok, "prepareVote should be Some") @@ -111,7 +312,7 @@ func TestNewInnerCommitVote(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) loaded, ok := i.CommitVote.Get() require.True(t, ok, "commitVote should be Some") @@ -132,7 +333,7 @@ func TestNewInnerTimeoutVote(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) loaded, ok := i.TimeoutVote.Get() require.True(t, ok, "timeoutVote should be Some") @@ -160,7 +361,7 @@ func TestNewInnerAllVotes(t *testing.T) { }) // Load and verify all - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be Some") require.True(t, i.CommitVote.IsPresent(), "commitVote should be Some") @@ -182,7 +383,7 @@ func TestNewInnerPartialState(t *testing.T) { }) // Load - only prepareVote should be present - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be Some") require.False(t, i.CommitVote.IsPresent(), "commitVote should be None") @@ -208,7 +409,7 @@ func TestNewInnerCommitQC(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.CommitQC.IsPresent(), "CommitQC should be loaded") loadedQC, ok := i.CommitQC.Get() @@ -245,7 +446,7 @@ func TestNewInnerTimeoutQC(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.TimeoutQC.IsPresent(), "TimeoutQC should be loaded") // View should be (6, 3) since TimeoutQC at (6, 2) advances to (6, 3) @@ -269,7 +470,7 @@ func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { }) // Load and verify - should work without CommitQC since index is 0 - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.TimeoutQC.IsPresent(), "TimeoutQC should be loaded") require.Equal(t, types.View{Index: 0, Number: 3}, i.View()) @@ -292,7 +493,7 @@ func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { }) // Should return error - TimeoutQC at index 6 requires CommitQC at index 5 - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -324,7 +525,7 @@ func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { }) // Should return error - TimeoutQC index must equal CommitQC.Index + 1 - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -357,7 +558,7 @@ func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { }) // Load - stale TimeoutQC should be treated as corrupt state - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -389,7 +590,7 @@ func TestNewInnerViewSpecValidBothQCs(t *testing.T) { }) // Load - both should be present - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.CommitQC.IsPresent(), "CommitQC should be loaded") require.True(t, i.TimeoutQC.IsPresent(), "TimeoutQC should be loaded") @@ -421,7 +622,7 @@ func TestNewInnerStaleVoteError(t *testing.T) { PrepareVote: utils.Some(staleVote), }) - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -450,7 +651,7 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { }) // Should return error - future votes indicate corrupt state - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -479,7 +680,7 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { }) // Should return error - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -507,7 +708,7 @@ func TestNewInnerFutureTimeoutVoteError(t *testing.T) { }) // Should return error - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -536,39 +737,11 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { }) // Should succeed - current view votes are valid - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.PrepareVote.IsPresent(), "current view vote should be loaded") } -func TestNewInnerCommitQCInvalidSignatureError(t *testing.T) { - rng := utils.TestRng() - dir := t.TempDir() - 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}) - vote := types.NewCommitVote(proposal) - var votes []*types.Signed[*types.CommitVote] - for _, k := range otherKeys { - votes = append(votes, types.Sign(k, vote)) - } - qc := types.NewCommitQC(votes) - - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(qc), - }) - - // Should return error - invalid signatures - _, err := loadInner(dir, registry) - require.Error(t, err) - require.Contains(t, err.Error(), "corrupt persisted state") -} - func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() @@ -600,7 +773,7 @@ func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { }) // Should return error - invalid signatures on TimeoutQC - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -630,7 +803,7 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { }) // Should return error - current view votes must have valid signatures - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -660,7 +833,7 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { PrepareVote: utils.Some(badVote), }) - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -679,7 +852,7 @@ func TestNewInnerPrepareQC(t *testing.T) { }) // Load and verify - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") } @@ -708,7 +881,7 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { PrepareQC: utils.Some(stalePrepareQC), }) - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -727,7 +900,7 @@ func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { CommitVote: utils.Some(commitVote), }) - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "CommitVote present without PrepareQC") } @@ -756,7 +929,7 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { }) // Should return error - future prepareQC indicates corrupt state - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -785,7 +958,7 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { }) // Should succeed - current view prepareQC is valid - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.PrepareQC.IsPresent(), "current view prepareQC should be loaded") } @@ -818,7 +991,7 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { }) // Should return error - current view prepareQC has invalid signatures - _, err := loadInner(dir, registry) + _, err := loadInner(t, dir, registry, keys) require.Error(t, err) require.Contains(t, err.Error(), "corrupt persisted state") } @@ -848,7 +1021,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { }) // Load state - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") @@ -900,7 +1073,7 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { }) // Load initial state and verify everything is present - i, err := loadInner(dir, registry) + i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) require.True(t, i.PrepareQC.IsPresent(), "prepareQC should be loaded") require.True(t, i.PrepareVote.IsPresent(), "prepareVote should be loaded") @@ -961,3 +1134,62 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { require.Error(t, err) require.ErrorIs(t, err, wantErr) } + +func newConsensusState(t *testing.T, registry *epoch.Registry, key types.SecretKey) *State { + t.Helper() + s, err := NewState(&Config{ + Key: key, + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + }, newTestDataState(registry)) + require.NoError(t, err) + return s +} + +func commitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadIndex) *types.CommitQC { + parent := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep, types.View{Index: idx - 1, Number: 0}))), + }) + qc := types.BuildCommitQC(ep, keys, utils.Some(parent), nil) + if qc.Proposal().Index() != idx { + panic("commitQCAtRoad: BuildCommitQC landed on unexpected index") + } + return qc +} + +func TestPushCommitQC_RotatesEpochAtBoundary(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + s := newConsensusState(t, registry, keys[0]) + require.Equal(t, types.EpochIndex(0), s.innerRecv.Load().epoch.EpochIndex()) + + ep0, ok := registry.EpochByIndex(0) + require.True(t, ok) + qc := commitQCAtRoad(ep0, keys, epoch.LastRoad(0)) + require.Equal(t, epoch.LastRoad(0), qc.Proposal().Index()) + + // Avail resolves the next-view epoch; pushSpecFromAvail installs it verbatim. + ep1, err := registry.EpochAt(epoch.FirstRoad(1)) + require.NoError(t, err) + require.NoError(t, s.pushSpecFromAvail(types.ConsensusSpec{CommitQC: qc, Epoch: ep1})) + got := s.innerRecv.Load() + require.Equal(t, types.EpochIndex(1), got.epoch.EpochIndex()) + require.Equal(t, epoch.FirstRoad(1), got.View().Index) +} + +func TestNewState_ErrAvailBehindConsensus(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + dir := t.TempDir() + + ep0, ok := registry.EpochByIndex(0) + require.True(t, ok) + qc := commitQCAtRoad(ep0, keys, 3) + seedPersistedInner(dir, &persistedInner{CommitQC: utils.Some(qc)}) + + _, err := NewState(&Config{ + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + PersistentStateDir: utils.Some(dir), + }, newTestDataState(registry)) + require.ErrorIs(t, err, ErrAvailBehindConsensus) +} diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 46e947a672..336e3c8e07 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -72,12 +72,6 @@ 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 { - if cqc, ok := p.CommitQC.Get(); ok { - if err := cqc.Verify(ep); err != nil { - return fmt.Errorf("corrupt persisted state: CommitQC failed verification: %w", err) - } - } - // TimeoutQC index must equal NextIndexOpt(CommitQC) (i.e., CommitQC.Index+1, or 0 if missing). // Since we persist the entire inner state atomically, a mismatched index is always corrupt. if tqc, ok := p.TimeoutQC.Get(); ok { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 42b2ab0d7b..c55a42c165 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" @@ -100,14 +101,19 @@ func newState( pers utils.Option[persist.Persister[*pb.PersistedInner]], persistedData utils.Option[*pb.PersistedInner], ) (*State, error) { - initialInner, err := newInner(persistedData, data.Registry()) + availState, err := avail.NewState(cfg.Key, data, cfg.PersistentStateDir) if err != nil { - return nil, fmt.Errorf("newInner: %w", err) + return nil, fmt.Errorf("avail.NewState: %w", err) } - availState, err := avail.NewState(cfg.Key, data, cfg.PersistentStateDir) + initialInner, err := newInner( + persistedData, + availState.SubscribeConsensusSpec().Load(), + data.Registry(), + ) if err != nil { - return nil, fmt.Errorf("avail.NewState: %w", err) + _ = availState.Close() + return nil, fmt.Errorf("newInner: %w", err) } innerSend := utils.Alloc(utils.NewAtomicSend(initialInner)) @@ -123,7 +129,7 @@ func newState( prepareVotes: utils.NewMutex(newPrepareVotes()), commitVotes: utils.NewMutex(newCommitVotes()), - myView: utils.NewAtomicSend(types.ViewSpec{Epoch: initialInner.epoch}), + myView: utils.NewAtomicSend(types.ViewSpec{CommitQC: initialInner.CommitQC, TimeoutQC: initialInner.TimeoutQC, Epoch: initialInner.epoch}), myProposal: utils.NewAtomicSend(utils.None[*types.FullProposal]()), myPrepareVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqPrepareVote]()), myCommitVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqCommitVote]()), @@ -133,6 +139,9 @@ func newState( return s, nil } +// ErrAvailBehindConsensus means the consensus WAL tip is ahead of ConsensusSpec. +var ErrAvailBehindConsensus = errors.New("consensus WAL tip ahead of ConsensusSpec") + // Close releases the availability state's WALs, and with them the exclusive lock each holds on its // directory. // @@ -306,15 +315,16 @@ func (s *State) Run(ctx context.Context) error { return nil }) }) - 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. - 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) + scope.SpawnNamed("pushSpecFromAvail", func() error { + // We pull the tip back from "avail" for dissemination. This ensures we + // only advance on CommitQCs that avail has verified, logged, and paired + // with the epoch of the next view — consensus resolves no epochs itself. + return s.avail.SubscribeConsensusSpec().Iter(ctx, func(ctx context.Context, specOpt utils.Option[types.ConsensusSpec]) error { + spec, ok := specOpt.Get() + if !ok { + return nil } - return nil + return s.pushSpecFromAvail(spec) }) }) scope.SpawnNamed("pushPrepareQC", func() error { diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 8a26705562..3bb857f7ab 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -31,8 +31,16 @@ type blockEntry struct { block *types.Block } +// qcEntry is an admitted FullCommitQC with the epoch used to verify it. +// The epoch is resolved once at admit (or load); later PushBlock / AppQC +// paths use this pointer instead of querying the registry again. +type qcEntry struct { + qc *types.FullCommitQC + epoch *types.Epoch +} + type inner struct { - qcs map[types.GlobalBlockNumber]*types.FullCommitQC // [first, nextQC) + qcs map[types.GlobalBlockNumber]qcEntry // [first, nextQC) blocks map[types.GlobalBlockNumber]*types.Block // [first, nextBlock) + gap-fills in [nextBlock, nextQC) appProposals map[types.GlobalBlockNumber]*types.AppProposal // [first, nextAppProposal) appQCs map[types.GlobalBlockNumber]*types.AppQC // [first, nextAppQC) @@ -52,6 +60,9 @@ type inner struct { // Anchor represents the highest fully processed row: // CommitQC, Blocks, AppProposal, AppQC present and persisted. anchor utils.AtomicSend[utils.Option[Anchor]] + // commitEpoch is the verify-epoch of the latest admitted CommitQC + // (genesis LatestEpoch when none yet). May lead AppQC/Anchor. + commitEpoch utils.AtomicSend[*types.Epoch] } // insertQC verifies and inserts a FullCommitQC into the inner state. @@ -73,13 +84,14 @@ func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error return fmt.Errorf("qc.Verify(): %w", err) } for i.nextQC < gr.Next { - i.qcs[i.nextQC] = qc + i.qcs[i.nextQC] = qcEntry{qc: qc, epoch: e} i.nextQC++ } + i.commitEpoch.Store(e) return nil } -func (i *inner) insertAppQC(registry *epoch.Registry, appQC *types.AppQC) error { +func (i *inner) insertAppQC(appQC *types.AppQC) error { gr := appQC.Proposal().GlobalRange() if gr.Next <= i.nextAppQC { return nil @@ -90,12 +102,8 @@ func (i *inner) insertAppQC(registry *epoch.Registry, appQC *types.AppQC) error if gr.First > i.nextAppQC { return fmt.Errorf("AppQC gap: expected first<=%d, got %d", i.nextAppQC, gr.First) } - ei := appQC.Proposal().EpochIndex() - epoch, ok := registry.EpochByIndex(ei) - if !ok { - return fmt.Errorf("unknown epoch_index %d", ei) - } - if err := appQC.Verify(epoch.Committee()); err != nil { + ep := i.qcs[i.nextAppQC].epoch + if err := appQC.Verify(ep.Committee()); err != nil { return fmt.Errorf("appQC.Verify(): %w", err) } for i.nextAppQC < gr.Next { @@ -116,7 +124,7 @@ func (i *inner) insertAppProposal(appProposal *types.AppProposal) error { if gr.First > i.nextAppProposal { return fmt.Errorf("AppProposal gap: expected first<=%d, got %d", i.nextAppProposal, gr.First) } - if err := appProposal.Verify(i.qcs[i.nextAppProposal].QC()); err != nil { + if err := appProposal.Verify(i.qcs[i.nextAppProposal].qc.QC()); err != nil { return fmt.Errorf("appProposal.Verify(): %w", err) } for i.nextAppProposal < gr.Next { @@ -145,7 +153,7 @@ func (i *inner) insertBlock(n types.GlobalBlockNumber, block *types.Block) error } // n is in [nextBlock, nextQC); QCs are contiguous and first <= // nextAppProposal <= nextBlock, so qcs[n] is always present. - qc := i.qcs[n] + qc := i.qcs[n].qc storedGR := qc.QC().GlobalRange() want := qc.Headers()[n-storedGR.First].Hash() got := block.Header().Hash() @@ -213,6 +221,18 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { if err != nil { return nil, fmt.Errorf("blockDB.ReadSuffix(): %w", err) } + var commitSpan utils.Option[types.RoadRange] + if qcs := suffix.CommitQCs; len(qcs) > 0 { + first := qcs[0].Index() + next := qcs[0].Index() + 1 + for _, qc := range qcs[1:] { + idx := qc.Index() + first = min(first, idx) + next = max(next, idx+1) + } + commitSpan = utils.Some(types.RoadRange{First: first, Next: next}) + } + cfg.Registry.SetupInitialEpochs(commitSpan) firstBlock := cfg.Registry.FirstBlock() status := suffix.Status.Or(types.SuffixRange{ First: firstBlock, @@ -222,7 +242,7 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { NextBlock: firstBlock, }) inner := &inner{ - qcs: map[types.GlobalBlockNumber]*types.FullCommitQC{}, + qcs: map[types.GlobalBlockNumber]qcEntry{}, blocks: map[types.GlobalBlockNumber]*types.Block{}, appQCs: map[types.GlobalBlockNumber]*types.AppQC{}, appProposals: map[types.GlobalBlockNumber]*types.AppProposal{}, @@ -234,6 +254,7 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { nextQC: status.First, persisted: status, anchor: utils.NewAtomicSend(utils.None[Anchor]()), + commitEpoch: utils.NewAtomicSend(cfg.Registry.LatestEpoch()), } for _, qc := range suffix.CommitQCs { if err := inner.insertQC(cfg.Registry, qc); err != nil { @@ -241,13 +262,8 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { } } for _, b := range suffix.Blocks { - qc := inner.qcs[b.Number] - ei := qc.QC().Proposal().EpochIndex() - e, ok := cfg.Registry.EpochByIndex(ei) - if !ok { - return nil, fmt.Errorf("unknown epoch_index %d", ei) - } - if err := b.Block.Verify(e.Committee()); err != nil { + entry := inner.qcs[b.Number] + if err := b.Block.Verify(entry.epoch.Committee()); err != nil { return nil, fmt.Errorf("verify block %d from BlockDB: %w", b.Number, err) } if err := inner.insertBlock(b.Number, b.Block); err != nil { @@ -263,7 +279,7 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { } } for _, appQC := range suffix.AppQCs { - if err := inner.insertAppQC(cfg.Registry, appQC); err != nil { + if err := inner.insertAppQC(appQC); err != nil { return nil, fmt.Errorf("load AppQC from BlockDB: %w", err) } } @@ -283,7 +299,7 @@ func (s *State) Registry() *epoch.Registry { return s.cfg.Registry } // when the contiguous prefix grows. Caller must hold inner's lock. func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash map[types.BlockHeaderHash]*types.Block) error { for n := max(inner.nextBlock, gr.First); n < min(gr.Next, inner.nextQC); n++ { - storedQC := inner.qcs[n] + storedQC := inner.qcs[n].qc storedGR := storedQC.QC().GlobalRange() if b, ok := byHash[storedQC.Headers()[n-storedGR.First].Hash()]; ok { if err := inner.insertBlock(n, b); err != nil { @@ -299,7 +315,6 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { - // Wait until QC is needed. ep, ok := s.cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) if !ok { return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) @@ -337,9 +352,10 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty for inner, ctrl := range s.inner.Lock() { if needQC { for inner.nextQC < gr.Next { - inner.qcs[inner.nextQC] = qc + inner.qcs[inner.nextQC] = qcEntry{qc: qc, epoch: ep} inner.nextQC += 1 } + inner.commitEpoch.Store(ep) ctrl.Updated() } if len(byHash) > 0 { @@ -364,7 +380,7 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC if n < inner.first { break } - return inner.qcs[n], nil + return inner.qcs[n].qc, nil } return s.qcFromDB(n) } @@ -374,7 +390,7 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC // the height is already in the contiguous block prefix (n < nextBlock) — in // that case the block is dropped silently (already stored or executed/evicted). 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 @@ -384,14 +400,10 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block if n < inner.nextBlock { return nil } - // n in [nextBlock, nextQC): QC is contiguous in that range. - epochIdx = inner.qcs[n].QC().Proposal().EpochIndex() + // n in [nextBlock, nextQC): QC (and its verify-epoch) is contiguous. + ep = inner.qcs[n].epoch } - ep, ok := s.cfg.Registry.EpochByIndex(epochIdx) - if !ok { - return fmt.Errorf("unknown epoch_index %d", epochIdx) - } - // Verify outside the lock against the known epoch. + // Verify outside the lock against the epoch stashed with the QC. if err := block.Verify(ep.Committee()); err != nil { return fmt.Errorf("block.Verify(): %w", err) } @@ -441,7 +453,7 @@ func (s *State) GlobalBlockByHash(hash types.BlockHeaderHash) (utils.Option[*typ // blockHashes stays in lockstep with blocks; a hit means both block and // covering QC are still cached (including n < nextAppProposal when // AppQC eviction has not advanced first past n yet). - return utils.Some(assembleGlobalBlock(n, inner.blocks[n], inner.qcs[n])), nil + return utils.Some(assembleGlobalBlock(n, inner.blocks[n], inner.qcs[n].qc)), nil } return s.globalBlockByHashFromDB(hash) } @@ -529,7 +541,7 @@ func (s *State) GlobalBlock(ctx context.Context, n types.GlobalBlockNumber) (*ty if n < inner.first { break } - return assembleGlobalBlock(n, inner.blocks[n], inner.qcs[n]), nil + return assembleGlobalBlock(n, inner.blocks[n], inner.qcs[n].qc), nil } return s.globalBlockFromDB(n) } @@ -602,7 +614,8 @@ func (s *State) globalBlockByHashFromDB(hash types.BlockHeaderHash) (utils.Optio return utils.Some(assembleGlobalBlock(bn.Number, bn.Block, qc)), nil } -// PushAppHash marks blocks up to n as executed. +// PushAppHash marks blocks up to n as executed and advances the epoch +// registry when n closes a CommitQC at an epoch boundary. func (s *State) PushAppHash(ctx context.Context, n types.GlobalBlockNumber, hash types.AppHash) error { for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { return n < inner.nextBlock }); err != nil { @@ -611,7 +624,7 @@ func (s *State) PushAppHash(ctx context.Context, n types.GlobalBlockNumber, hash if n < inner.nextAppProposal { return nil } - p := inner.qcs[n].QC().Proposal() + p := inner.qcs[n].qc.QC().Proposal() if next, first := inner.nextAppProposal, p.GlobalRange().First; next < first { // We expect the AppHashes to be pushed in order. return fmt.Errorf("received appHash for %v: %w", n, ErrOutOfOrder) @@ -637,6 +650,11 @@ func (s *State) PushAppHash(ctx context.Context, n types.GlobalBlockNumber, hash inner.nextAppProposal += 1 } s.metrics.NextBlock.Execute.Set(utils.Clamp[int64](inner.nextAppProposal)) + // Seed cursor: at LastRoad(N) register N+1 so runEpochAdvance can install + // it once seal and the prune/execution leashes are met. N+2 is not needed — + // ConsensusSpec withholds the view after LastRoad(N+1) until this fires + // again. + s.cfg.Registry.AdvanceIfNeeded(p.Index()) ctrl.Updated() // CRITICAL: We need to persist AppHash before we return and start executing the next block, // otherwise we lose the apphash on restart. @@ -680,7 +698,7 @@ func (s *State) PushAppQC(ctx context.Context, appQC *types.AppQC) error { if gr.First < inner.nextAppQC { return nil } - if err := inner.insertAppQC(s.cfg.Registry, appQC); err != nil { + if err := inner.insertAppQC(appQC); err != nil { return err } t := time.Now() @@ -712,6 +730,8 @@ func (s *State) AppQC(ctx context.Context, n types.GlobalBlockNumber) (*types.Ap type Anchor struct { CommitQC *types.CommitQC AppQC *types.AppQC + // Epoch is the verify-epoch of CommitQC, stashed at admit. + Epoch *types.Epoch } // Anchor represents the AppQC/CommitQC covering inner.first. @@ -723,12 +743,22 @@ func (s *State) Anchor() utils.AtomicRecv[utils.Option[Anchor]] { panic("unreachable") } +// CommitEpoch returns the verify-epoch of the latest admitted CommitQC, or the +// genesis epoch when none has been admitted. It may lead AppQC/Anchor. +// Used by giga EVM tx sharding (EvmProxy / EvmShard). +func (s *State) CommitEpoch() utils.AtomicRecv[*types.Epoch] { + for inner := range s.inner.Lock() { + return inner.commitEpoch.Subscribe() + } + panic("unreachable") +} + func (i *inner) nextToExecute(lane types.LaneID) types.BlockNumber { if i.nextAppProposal < i.nextQC { - return i.qcs[i.nextAppProposal].QC().LaneRange(lane).First() + return i.qcs[i.nextAppProposal].qc.QC().LaneRange(lane).First() } if i.first < i.nextAppProposal { - return i.qcs[i.nextAppProposal-1].QC().LaneRange(lane).Next() + return i.qcs[i.nextAppProposal-1].qc.QC().LaneRange(lane).Next() } // Genesis state: i.first == i.nextQC return 0 @@ -795,7 +825,7 @@ func (s *State) runPersist(ctx context.Context) error { } // Collect data to persist. for status.NextQC < inner.nextQC { - qc := inner.qcs[status.NextQC] + qc := inner.qcs[status.NextQC].qc qcs = append(qcs, qc) status.NextQC = qc.QC().GlobalRange().Next } @@ -871,9 +901,11 @@ func (s *State) runPersist(ctx context.Context) error { func (i *inner) setAnchor() { if i.first < i.persisted.NextAppQC { + entry := i.qcs[i.first] i.anchor.Store(utils.Some(Anchor{ - CommitQC: i.qcs[i.first].QC(), + CommitQC: entry.qc.QC(), AppQC: i.appQCs[i.first], + Epoch: entry.epoch, })) } } diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index ee80268b65..9644ccabf2 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -430,3 +430,30 @@ func TestRecoveryBlockGap(t *testing.T) { require.NoError(t, err) require.Equal(t, mid, state.NextBlock(), "replay must resume at the first unfilled number") } + +func TestNewState_SetupInitialEpochsFromCommitQCSpan(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 4) + qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + + db := memblock.NewBlockDB() + t.Cleanup(func() { require.NoError(t, db.Close()) }) + writeToBlockDB(t, db, []*types.FullCommitQC{qc}, [][]*types.Block{blocks}) + + _, err := registry.EpochAt(epoch.FirstRoad(1)) + require.NoError(t, err, "precondition: genesis epochs 0 and 1 are registered") + _, err = registry.EpochAt(epoch.FirstRoad(2)) + require.Error(t, err, "precondition: epoch 2 absent before NewState") + + _, err = NewState(&Config{Registry: registry}, db) + require.NoError(t, err) + + for _, idx := range []types.EpochIndex{0, 1} { + if _, err := registry.EpochAt(epoch.FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after NewState: %v", idx, err) + } + } + if _, err := registry.EpochAt(epoch.FirstRoad(2)); err == nil { + t.Fatal("epoch 2 should not be seeded from a single epoch-0 CommitQC") + } +} diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 30554a7abc..eaa7178af2 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -33,11 +33,14 @@ func newSnapshot() Snapshot { func snapshot(s *State) Snapshot { for inner := range s.inner.Lock() { - aps := maps.Clone(inner.appProposals) + qcs := make(map[types.GlobalBlockNumber]*types.FullCommitQC, len(inner.qcs)) + for n, e := range inner.qcs { + qcs[n] = e.qc + } return Snapshot{ - QCs: maps.Clone(inner.qcs), + QCs: qcs, Blocks: maps.Clone(inner.blocks), - AppProposals: aps, + AppProposals: maps.Clone(inner.appProposals), } } panic("unreachable") @@ -111,6 +114,26 @@ func pushAppQCForBlock(ctx context.Context, state *State, keys []types.SecretKey return state.PushAppQC(ctx, TestAppQC(keys, vote.Proposal())) } +func TestCommitEpoch_TracksLatestCommitQC(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 3) + state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) + require.Equal(t, registry.LatestEpoch(), state.CommitEpoch().Load()) + + require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + s.SpawnBgNamed("state.Run()", func() error { + return utils.IgnoreCancel(state.Run(ctx)) + }) + qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + if err := state.PushQC(ctx, qc, blocks); err != nil { + return err + } + require.Equal(t, registry.LatestEpoch(), state.CommitEpoch().Load()) + return nil + })) +} + func TestState(t *testing.T) { ctx := t.Context() rng := utils.TestRng() @@ -422,6 +445,63 @@ func TestPushAppHashRejectsJumpOverCommitQCRange(t *testing.T) { })) } +func TestPushAppHash_AdvancesRegistryAtEpochBoundary(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + + t.Run("mid-epoch road does not seed epoch 2", func(t *testing.T) { + registry, keys := epoch.GenRegistry(rng, 3) + state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) + require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) + qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + if err := state.PushQC(ctx, qc, blocks); err != nil { + return err + } + if err := state.PushAppHash(ctx, qc.QC().GlobalRange().Next-1, types.GenAppHash(rng)); err != nil { + return err + } + if _, err := registry.EpochAt(epoch.FirstRoad(2)); err == nil { + return fmt.Errorf("epoch 2 must stay absent for road %d", qc.QC().Proposal().Index()) + } + return nil + })) + }) + + t.Run("LastRoad does not seed epoch 2", func(t *testing.T) { + registry, keys := epoch.GenRegistry(rng, 3) + state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) + ep := registry.LatestEpoch() + require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) + // A valid QC stamped at the epoch boundary: ProposalAt finalizes one block on + // lane 0 starting at ep.FirstBlock(), which a fresh state admits since PushQC + // requires global-block contiguity, not road contiguity. block must stay + // identical to the one ProposalAt builds, or the header hashes disagree. + proposal := types.ProposalAt(ep, types.View{Index: epoch.LastRoad(0), Number: 0}) + block := types.NewBlock(ep.Committee().Lanes().At(0), 0, types.BlockHeaderHash{}, &types.Payload{}) + votes := make([]*types.Signed[*types.CommitVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, types.NewCommitVote(proposal))) + } + qc := types.NewFullCommitQC(types.NewCommitQC(votes), []*types.BlockHeader{block.Header()}) + if qc.QC().Proposal().Index() != epoch.LastRoad(0) { + return fmt.Errorf("road = %d, want %d", qc.QC().Proposal().Index(), epoch.LastRoad(0)) + } + if err := state.PushQC(ctx, qc, []*types.Block{block}); err != nil { + return err + } + if err := state.PushAppHash(ctx, qc.QC().GlobalRange().Next-1, types.GenAppHash(rng)); err != nil { + return err + } + if _, err := registry.EpochAt(epoch.FirstRoad(2)); err == nil { + return fmt.Errorf("PushAppHash at LastRoad(0) must not seed epoch 2") + } + return nil + })) + }) +} + func TestPushBlockAcceptsBlockWithQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() @@ -589,7 +669,7 @@ func TestEvictionWaitsForAppQC(t *testing.T) { if inner.first != evictionBound { return fmt.Errorf("after catching up, first = %d, want eviction bound %d", inner.first, evictionBound) } - if anchor, ok := inner.anchor.Load().Get(); !ok || anchor.AppQC != inner.appQCs[inner.first] || anchor.CommitQC != inner.qcs[inner.first].QC() { + if anchor, ok := inner.anchor.Load().Get(); !ok || anchor.AppQC != inner.appQCs[inner.first] || anchor.CommitQC != inner.qcs[inner.first].qc.QC() { return fmt.Errorf("anchor must cover inner.first %d", inner.first) } for n := gr1.First; n < inner.first; n++ { @@ -743,7 +823,7 @@ func TestNextToExecuteAfterAppEviction(t *testing.T) { if inner.nextAppProposal >= inner.nextQC { return fmt.Errorf("nextAppProposal = %d, want < nextQC %d", inner.nextAppProposal, inner.nextQC) } - fqc := inner.qcs[inner.nextAppProposal] + fqc := inner.qcs[inner.nextAppProposal].qc if fqc == nil { return fmt.Errorf("QC %d missing", inner.nextAppProposal) } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 46e36fde32..dacdbb3d32 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -1,104 +1,218 @@ 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" ) +// 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. +func LastRoad(idx types.EpochIndex) types.RoadIndex { + return FirstRoad(idx+1) - 1 +} + type registryState struct { m map[types.EpochIndex]*types.Epoch latest types.EpochIndex } -// Registry is the authoritative source of epoch and committee information. -// All layers (consensus, data, avail) read from it. +// Registry stores activated epochs and placeholders. type Registry struct { - state utils.RWMutex[*registryState] + state utils.Watch[*registryState] } -// NewRegistry creates a Registry with the genesis committee. +// NewRegistry creates a Registry with genesis epochs 0 and 1 (genesis committee). func NewRegistry( committee *types.Committee, firstBlock types.GlobalBlockNumber, genesisTimestamp time.Time, ) (*Registry, error) { - ep := types.NewEpoch(0, types.OpenRoadRange(), genesisTimestamp, committee, firstBlock) + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: FirstRoad(1)}, genesisTimestamp, committee, firstBlock) + ep1 := types.NewEpoch(1, types.RoadRange{First: FirstRoad(1), Next: FirstRoad(2)}, genesisTimestamp, committee, firstBlock) return &Registry{ - state: utils.NewRWMutex(®istryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep}, + state: utils.NewWatch(®istryState{ + m: map[types.EpochIndex]*types.Epoch{0: ep0, 1: ep1}, latest: 0, }), }, 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. +// SetupInitialEpochs registers placeholders covering commitQCs and the next epoch. +// With no CommitQCs this is a no-op (epochs 0 and 1 are already present). +func (r *Registry) SetupInitialEpochs(commitQCs utils.Option[types.RoadRange]) { + span, ok := commitQCs.Get() + if !ok { + return + } + for s, ctrl := range r.state.Lock() { + windowFirst := IndexForRoad(span.First) + windowLast := IndexForRoad(span.Next - 1) + r.ensureAround(s, span.First) + for idx := windowFirst; idx <= windowLast; idx++ { + r.ensureLocked(s, idx) + } + r.ensureAround(s, span.Next) + // TODO: replace placeholders with execution-derived committee, + // FirstTimestamp, and FirstBlock (genesis copies feed ViewSpec). + r.ensureLocked(s, windowLast+1) + ctrl.Updated() + } +} + +// FirstBlock returns the genesis epoch's first global block number. func (r *Registry) FirstBlock() types.GlobalBlockNumber { - for s := range r.state.RLock() { + for s := range r.state.Lock() { return s.m[0].FirstBlock() } panic("unreachable") } -// GenesisTimestamp returns the timestamp of the genesis epoch. +// GenesisTimestamp returns the genesis epoch timestamp. func (r *Registry) GenesisTimestamp() time.Time { - for s := range r.state.RLock() { + for s := range r.state.Lock() { return s.m[0].FirstTimestamp() } panic("unreachable") } -// EpochByIndex returns the epoch with the given index, if it exists. +// EpochByIndex returns the registered epoch at idx, if any. func (r *Registry) EpochByIndex(idx types.EpochIndex) (*types.Epoch, bool) { - for s := range r.state.RLock() { + for s := range r.state.Lock() { ep, ok := s.m[idx] return ep, ok } panic("unreachable") } -// LatestEpoch returns the most recently activated epoch. +// EpochAt returns the registered epoch containing roadIndex. +func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { + epochIdx := IndexForRoad(roadIndex) + for s := range r.state.Lock() { + if ep, ok := s.m[epochIdx]; ok { + return ep, nil + } + return nil, fmt.Errorf("epoch %d (road %d) not registered", epochIdx, roadIndex) + } + panic("unreachable") +} + +// LatestEpoch returns the ActivateEpoch tip. func (r *Registry) LatestEpoch() *types.Epoch { - for s := range r.state.RLock() { + for s := range r.state.Lock() { return s.m[s.latest] } panic("unreachable") } +// ActivateEpoch registers the next vacant epoch after LatestEpoch with the given +// committee weights. Already-registered epochs are never modified. The first +// activation lands at index ≥ 2 (epochs 0 and 1 are always present). The new +// epoch's road range is FirstRoad(index)..FirstRoad(index+1). func (r *Registry) ActivateEpoch( weights map[types.PublicKey]uint64, - roads types.RoadRange, firstTimestamp time.Time, firstBlock types.GlobalBlockNumber, ) (*types.Epoch, error) { - for s := range r.state.Lock() { - prev := s.m[s.latest] + for s, ctrl := range r.state.Lock() { next := s.latest + 1 + for { + if _, ok := s.m[next]; !ok { + break + } + next++ + } + prev := s.m[next-1] committee, err := prev.Committee().DeriveNext(weights, next) if err != nil { return nil, err } + roads := types.RoadRange{First: FirstRoad(next), Next: FirstRoad(next + 1)} ep := types.NewEpoch(next, roads, firstTimestamp, committee, firstBlock) s.m[next] = ep s.latest = next + ctrl.Updated() return ep, nil } panic("unreachable") } -// VerifyInWindow calls fn against the latest epoch's committee and returns it if accepted. -// Returns a slice of all matching epochs so callers can skip re-verification for any -// epoch already checked here. -// TODO(#3736): 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 +// makeEpoch inserts a genesis-committee placeholder at epochIdx. +// Caller must hold r.state. Epochs 0 and 1 are always present (seeded at +// construction with the genesis committee); further epochs copy from epoch 0. +func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types.Epoch { + ep0 := s.m[0] + firstRoad := FirstRoad(epochIdx) + epoch := types.NewEpoch( + epochIdx, + types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)}, + ep0.FirstTimestamp(), + ep0.Committee(), + ep0.FirstBlock(), + ) + s.m[epochIdx] = epoch + return epoch +} + +// ensureLocked registers a genesis-committee placeholder for idx if missing. +// Caller must hold r.state. +func (r *Registry) ensureLocked(s *registryState, idx types.EpochIndex) { + if _, ok := s.m[idx]; !ok { + r.makeEpoch(s, idx) + } +} + +// ensureAround registers the epoch containing road and its predecessor. +// Caller must hold r.state. +func (r *Registry) ensureAround(s *registryState, road types.RoadIndex) { + center := IndexForRoad(road) + if center > 0 { + r.ensureLocked(s, center-1) + } + r.ensureLocked(s, center) +} + +// AdvanceIfNeeded registers epoch M+1 when roadIndex is LastRoad(M). +// M+2 is not seeded: tip may race to LastRoad(M+1) before AppQC, but +// ConsensusSpec withholds that next view until M+1's AppQC boundary fires +// AdvanceIfNeeded again. +func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { + tipEpoch := IndexForRoad(roadIndex) + if roadIndex != LastRoad(tipEpoch) { + return + } + for s, ctrl := range r.state.Lock() { + r.ensureLocked(s, tipEpoch+1) + ctrl.Updated() + } +} + +// WaitForEpoch blocks until epoch i is registered. +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.m[i]; ok { + return ep, nil + } + if err := ctrl.Wait(ctx); err != nil { + return nil, err + } } - return []*types.Epoch{ep}, nil } panic("unreachable") } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index f5249bcd44..40ffd79685 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -2,10 +2,12 @@ package epoch import ( "testing" + "testing/synctest" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) func makeRegistry(t *testing.T) (*Registry, *types.Committee) { @@ -20,6 +22,10 @@ func makeRegistry(t *testing.T) (*Registry, *types.Committee) { return r, committee } +func midRoad(idx types.EpochIndex) types.RoadIndex { + return FirstRoad(idx) + EpochLength/2 +} + func TestRegistry_EpochByIndex_UnknownReturnsNotFound(t *testing.T) { r, _ := makeRegistry(t) if _, ok := r.EpochByIndex(99); ok { @@ -37,3 +43,180 @@ func TestRegistry_EpochByIndex_GenesisFound(t *testing.T) { t.Fatalf("EpochIndex() = %d, want 0", ep.EpochIndex()) } } + +func TestNewRegistry_GenesisEpochBoundedRange(t *testing.T) { + r, _ := makeRegistry(t) + ep0, err := r.EpochAt(0) + if err != nil { + t.Fatalf("EpochAt(0): %v", err) + } + rng0 := ep0.RoadRange() + if rng0.First != 0 || rng0.Next != FirstRoad(1) { + t.Fatalf("epoch 0 RoadRange = {%d, %d}, want {0, %d}", rng0.First, rng0.Next, FirstRoad(1)) + } + ep1, err := r.EpochAt(FirstRoad(1)) + if err != nil { + t.Fatalf("EpochAt(FirstRoad(1)): %v", err) + } + rng1 := ep1.RoadRange() + if rng1.First != FirstRoad(1) || rng1.Next != FirstRoad(2) { + t.Fatalf("epoch 1 RoadRange = {%d, %d}, want {%d, %d}", rng1.First, rng1.Next, FirstRoad(1), FirstRoad(2)) + } + if ep1.Committee() != ep0.Committee() { + t.Fatal("epoch 1 must use the genesis committee") + } +} + +func TestEpochAt_WithinGenesisEpoch(t *testing.T) { + r, _ := makeRegistry(t) + ep, err := r.EpochAt(LastRoad(0)) + if err != nil { + t.Fatalf("EpochAt(LastRoad(0)) error: %v", err) + } + if ep.EpochIndex() != 0 { + 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) + if _, err := r.EpochAt(FirstRoad(1)); err != nil { + t.Fatalf("epoch 1 must be present from NewRegistry: %v", err) + } + 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(1)) + if err != nil { + t.Fatalf("EpochAt(FirstRoad(1)) after last road of epoch 0: %v", err) + } + if ep.EpochIndex() != 1 { + t.Fatalf("EpochAt(FirstRoad(1)).EpochIndex() = %d, want 1", ep.EpochIndex()) + } + if _, err := r.EpochAt(FirstRoad(2)); err == nil { + t.Fatal("AdvanceIfNeeded must not seed epoch 2") + } +} + +func TestSetupInitialEpochs_EmptyNoneIsNoOp(t *testing.T) { + r, _ := makeRegistry(t) + r.SetupInitialEpochs(utils.None[types.RoadRange]()) + for _, idx := range []types.EpochIndex{0, 1} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after empty None: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(2)); err == nil { + t.Fatal("EpochAt(epoch 2) should not be present from empty None") + } +} + +func TestSetupInitialEpochs_CommitQCMidSeedsPlaceholderNext(t *testing.T) { + r, _ := makeRegistry(t) + tip := midRoad(5) + r.SetupInitialEpochs(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + for _, idx := range []types.EpochIndex{4, 5, 6} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after CommitQC seeding: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present from mid-epoch CommitQC") + } +} + +func TestSetupInitialEpochs_CommitQCClosingSeedsNext(t *testing.T) { + r, _ := makeRegistry(t) + tip := LastRoad(5) + r.SetupInitialEpochs(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) + for _, idx := range []types.EpochIndex{4, 5, 6} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after closing CommitQC: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present past windowLast+1") + } +} + +func TestSetupInitialEpochs_CommitSpanFromFirst(t *testing.T) { + r, _ := makeRegistry(t) + r.SetupInitialEpochs(utils.Some(types.RoadRange{ + First: midRoad(2), + Next: midRoad(5) + 1, + })) + for _, idx := range []types.EpochIndex{1, 2, 3, 4, 5, 6} { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d) after commit span seeding: %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(7)); err == nil { + t.Fatal("EpochAt(epoch 7) should not be present past placeholder windowLast+1") + } +} + +func TestActivateEpoch_SkipsExistingSeeds(t *testing.T) { + r, committee := makeRegistry(t) + r.SetupInitialEpochs(utils.None[types.RoadRange]()) + require.Equal(t, types.EpochIndex(0), r.LatestEpoch().EpochIndex()) + seeded, ok := r.EpochByIndex(1) + require.True(t, ok) + seededCommittee := seeded.Committee() + + pk := committee.Lanes().At(0).Validator + ep, err := r.ActivateEpoch( + map[types.PublicKey]uint64{pk: 1}, + time.Time{}, + r.FirstBlock(), + ) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(2), ep.EpochIndex()) + require.Equal(t, types.EpochIndex(2), r.LatestEpoch().EpochIndex()) + require.Equal(t, FirstRoad(2), ep.RoadRange().First) + require.Equal(t, FirstRoad(3), ep.RoadRange().Next) + got, ok := r.EpochByIndex(1) + require.True(t, ok) + require.Equal(t, seededCommittee, got.Committee()) + _, ok = ep.Committee().Lane(pk).Get() + require.True(t, ok) + require.Equal(t, 1, ep.Committee().Lanes().Len()) +} + +func TestWaitForEpoch_FastPathAndWait(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + r, _ := makeRegistry(t) + ep, err := r.WaitForEpoch(t.Context(), 0) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(0), ep.EpochIndex()) + + ep, err = r.WaitForEpoch(t.Context(), 1) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(1), ep.EpochIndex()) + + _, err = r.EpochAt(FirstRoad(2)) + require.Error(t, err) + + var got *types.Epoch + var waitErr error + go func() { + got, waitErr = r.WaitForEpoch(t.Context(), 2) + }() + synctest.Wait() + require.Nil(t, got, "WaitForEpoch returned before AdvanceIfNeeded") + + r.AdvanceIfNeeded(LastRoad(1)) + synctest.Wait() + require.NoError(t, waitErr) + require.Equal(t, types.EpochIndex(2), got.EpochIndex()) + }) +} diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index d11471ef92..35af7e8ccf 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -15,6 +15,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/memblock" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/avail" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/epoch" @@ -575,12 +576,14 @@ func TestProducer_LeaveCancelsAndRejoinStartsNewLane(t *testing.T) { epLeave, err := registry.ActivateEpoch( map[types.PublicKey]uint64{b.Public(): 1}, - types.OpenRoadRange(), time.Time{}, registry.FirstBlock(), + time.Time{}, registry.FirstBlock(), ) if err != nil { return err } - availState.ApplyEpoch(epLeave) + if err := avail.DriveAdvance(ctx, availState, keys, epLeave.EpochIndex()); err != nil { + return err + } if err := availState.WaitUntilClosed(ctx, lane0); err != nil { return err } @@ -601,12 +604,14 @@ func TestProducer_LeaveCancelsAndRejoinStartsNewLane(t *testing.T) { epJoin, err := registry.ActivateEpoch( map[types.PublicKey]uint64{a.Public(): 1, b.Public(): 1}, - types.OpenRoadRange(), time.Time{}, registry.FirstBlock(), + time.Time{}, registry.FirstBlock(), ) if err != nil { return err } - availState.ApplyEpoch(epJoin) + if err := avail.DriveAdvance(ctx, availState, keys, epJoin.EpochIndex()); err != nil { + return err + } got, err := availState.WaitForNextLane(ctx, a.Public(), utils.Some(lane0)) if err != nil { return err @@ -644,10 +649,15 @@ func TestInsertTx_WaitUnblocksOnLeave(t *testing.T) { epLeave, err := registry.ActivateEpoch( map[types.PublicKey]uint64{b.Public(): 1}, - types.OpenRoadRange(), time.Time{}, registry.FirstBlock(), + time.Time{}, registry.FirstBlock(), ) require.NoError(t, err) - availState.ApplyEpoch(epLeave) + require.NoError(t, scope.Run(ctx, func(ctx context.Context, sc scope.Scope) error { + sc.SpawnBgNamed("avail", func() error { + return utils.IgnoreCancel(availState.Run(ctx)) + }) + return avail.DriveAdvance(ctx, availState, keys, epLeave.EpochIndex()) + })) env.state.clearMempool() select { diff --git a/sei-tendermint/internal/p2p/giga/avail.go b/sei-tendermint/internal/p2p/giga/avail.go index ba946628f4..8cc9348645 100644 --- a/sei-tendermint/internal/p2p/giga/avail.go +++ b/sei-tendermint/internal/p2p/giga/avail.go @@ -114,16 +114,23 @@ func (x *validatorService) serverStreamCommitQCs(ctx context.Context, server rpc func (x *validatorService) clientStreamLaneProposals(ctx context.Context, c rpc.Client[API], peer types.PublicKey) error { a := x.state.Avail() + var closed utils.Option[types.LaneID] for ctx.Err() == nil { // Wait on the peer's current committee LaneID: returns immediately for a - // stay/transient redial, blocks across leave until rejoin. - lane, err := a.WaitForNextLane(ctx, peer, utils.None[types.LaneID]()) + // stay/transient redial, blocks across leave until rejoin (exclude closed). + lane, err := a.WaitForNextLane(ctx, peer, closed) if err != nil { return err } if err := x.streamLaneProposalsOnce(ctx, c, lane, a.NextBlock(lane)); err != nil { return err } + cur, ok := a.Lane(peer).Get() + if !ok || cur != lane { + closed = utils.Some(lane) + } else { + closed = utils.None[types.LaneID]() + } } return ctx.Err() } diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index fbaacc02c5..5b409c65fc 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -41,6 +41,10 @@ type gigaRouterCommon struct { poolOut *giga.Pool[NodePublicKey, rpc.Client[giga.API]] proxies utils.RWMutex[map[atypes.PublicKey]*ethrpc.Client] app *proxy.Proxy + // commitEpoch is data.CommitEpoch() cached at construction so EvmProxy + // can Load() without taking the data lock on every call. Used for EVM + // tx sharding (Committee.EvmShard). + commitEpoch utils.AtomicRecv[*atypes.Epoch] // inboundFullnodeCount tracks live non-committee inbound block-sync // connections. Optimistic Add(1) + compare against cap; over-rejects diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index c8bcd721c0..2507d8620e 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -30,6 +30,7 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey, dataS cfg: cfg, key: key, data: dataState, + commitEpoch: dataState.CommitEpoch(), service: giga.NewFullNodeService(dataState), poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), @@ -65,7 +66,7 @@ func (r *gigaFullnodeRouter) Run(ctx context.Context) error { // EvmProxy on the fullnode always returns the shard owner's EVM RPC client. // EnableEvmProxy is a no-op here because fullnodes do not have a local mempool. func (r *gigaFullnodeRouter) EvmProxy(sender common.Address) utils.Option[*ethrpc.Client] { - return r.evmProxy(r.data.Registry().LatestEpoch().Committee().EvmShard(sender)) + return r.evmProxy(r.commitEpoch.Load().Committee().EvmShard(sender)) } // runFullnodeSubscriber: pick a committee member, dial + block-sync, diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go index 41a51fa38e..833af2922b 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go @@ -101,7 +101,7 @@ func TestGigaRouter_Fullnode(t *testing.T) { returnedRemoteClients := map[*ethrpc.Client]struct{}{} for range 200 { sender := common.BytesToAddress(utils.GenBytes(rng, common.AddressLength)) - shardValidator := router.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := router.data.CommitEpoch().Load().Committee().EvmShard(sender) expectedClient := clientByValidator[shardValidator] proxyClient, ok := router.EvmProxy(sender).Get() require.True(t, ok) diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 18c493979c..3757f21285 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -45,6 +45,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey, dataSta cfg: &cfg.GigaRouterCommonConfig, key: key, data: dataState, + commitEpoch: dataState.CommitEpoch(), service: giga.NewService(consensusState), poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), @@ -105,7 +106,7 @@ func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*ethr if !r.cfg.EnableEvmProxy { return utils.None[*ethrpc.Client]() } - validator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender) + validator := r.commitEpoch.Load().Committee().EvmShard(sender) if r.validatorKey == validator { return utils.None[*ethrpc.Client]() } diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 1a435f90a3..e9ec07668a 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -309,7 +309,7 @@ func TestGigaRouter_EvmProxy(t *testing.T) { seenDisconnected := false for range 400 { sender := common.BytesToAddress(utils.GenBytes(rng, common.AddressLength)) - shardValidator := router.data.Registry().LatestEpoch().Committee().EvmShard(sender) + shardValidator := router.data.CommitEpoch().Load().Committee().EvmShard(sender) proxyClient, ok := router.EvmProxy(sender).Get() From 1cf985d385dc8284068e4028eed8839a9299508c Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 17 Aug 2026 16:29:15 -0700 Subject: [PATCH 02/19] fix(autobahn): verify outside lock and derive Joined from activate tip Split payload/signature checks from committee membership so crypto stays off the avail lock, derive ActivateEpoch from LatestEpoch so rejoin gets a fresh LaneID.Joined, and cover reweight QC form/drop. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/block.go | 11 ---- .../autobahn/types/lane_proposal.go | 27 +++++++- sei-tendermint/autobahn/types/msg.go | 9 ++- sei-tendermint/autobahn/types/testonly.go | 2 +- .../autobahn/avail/block_votes_test.go | 65 +++++++++++++++++++ .../internal/autobahn/avail/state.go | 24 +++---- .../internal/autobahn/avail/state_test.go | 4 +- .../internal/autobahn/data/state.go | 25 +++++-- .../internal/autobahn/epoch/registry.go | 3 +- .../internal/autobahn/epoch/registry_test.go | 35 ++++++++++ 10 files changed, 167 insertions(+), 38 deletions(-) diff --git a/sei-tendermint/autobahn/types/block.go b/sei-tendermint/autobahn/types/block.go index 3e66a046d7..716683743f 100644 --- a/sei-tendermint/autobahn/types/block.go +++ b/sei-tendermint/autobahn/types/block.go @@ -139,17 +139,6 @@ func (b *Block) Header() *BlockHeader { return b.header } // Payload . func (b *Block) Payload() *Payload { return b.payload } -// Verify validates the Block. -func (b *Block) Verify(c *Committee) error { - if err := b.Header().Verify(c); err != nil { - return fmt.Errorf("header.Verify(): %w", err) - } - if got, want := b.Payload().Hash(), b.Header().PayloadHash(); got != want { - return fmt.Errorf("payload.Hash() = %v, want %v", got, want) - } - return nil -} - // Hash of the BlockHeader. func (h *BlockHeader) Hash() BlockHeaderHash { return BlockHeaderHash(hashable.ToHash(BlockHeaderConv.Encode(h))) diff --git a/sei-tendermint/autobahn/types/lane_proposal.go b/sei-tendermint/autobahn/types/lane_proposal.go index 5e23fecc3e..4c32ad2467 100644 --- a/sei-tendermint/autobahn/types/lane_proposal.go +++ b/sei-tendermint/autobahn/types/lane_proposal.go @@ -22,9 +22,30 @@ func NewLaneProposal(block *Block) *LaneProposal { // Block . func (m *LaneProposal) Block() *Block { return m.block } -// Verify verifies that the LaneProposal is consistent with the Committee. -func (m *LaneProposal) Verify(c *Committee) error { - return m.block.Verify(c) +// VerifyPayload checks that the payload hashes to the header payload hash. +func (m *LaneProposal) VerifyPayload() error { + b := m.block + if got, want := b.payload.Hash(), b.header.payloadHash; got != want { + return fmt.Errorf("payload.Hash() = %v, want %v", got, want) + } + return nil +} + +// VerifyCommitteeMembership checks that the proposal's lane is in the committee. +func (m *LaneProposal) VerifyCommitteeMembership(c *Committee) error { + return m.block.header.Verify(c) +} + +// VerifyLaneProposalPayloadAndSignature verifies payload hash and signature. It +// does not check committee membership. +func VerifyLaneProposalPayloadAndSignature(p *Signed[*LaneProposal]) error { + if err := p.Msg().VerifyPayload(); err != nil { + return fmt.Errorf("VerifyPayload(): %w", err) + } + if err := p.VerifySignature(); err != nil { + return fmt.Errorf("VerifySignature(): %w", err) + } + return nil } // LaneProposalConv is a protobuf converter for LaneProposal. diff --git a/sei-tendermint/autobahn/types/msg.go b/sei-tendermint/autobahn/types/msg.go index 624e878421..6f0fab5f62 100644 --- a/sei-tendermint/autobahn/types/msg.go +++ b/sei-tendermint/autobahn/types/msg.go @@ -188,12 +188,17 @@ func (m *Signed[T]) Sig() *Signature { return m.sig } // Key returns the key whish signed the message. func (m *Signed[T]) Key() PublicKey { return m.sig.key } -// VerifySig verifies the signature of the message. +// VerifySignature verifies the cryptographic signature. +func (m *Signed[T]) VerifySignature() error { + return m.sig.key.key.VerifyWithTag(autobahnTag, m.hashed.hash[:], m.sig.sig) +} + +// VerifySig verifies the signer is a committee replica and the signature. func (m *Signed[T]) VerifySig(c *Committee) error { if !c.HasReplica(m.sig.key) { return fmt.Errorf("%q is not a replica", m.sig.key) } - return m.sig.key.key.VerifyWithTag(autobahnTag, m.hashed.hash[:], m.sig.sig) + return m.VerifySignature() } // verifyQC verifies a slice of signatures and checks if they form a quorum. diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index 0ec6a5c3a1..fb6da8bb5f 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -146,7 +146,7 @@ func SignedForTesting[T Msg](msg T, sig *Signature) *Signed[T] { // NewBlockForTesting builds a Block with an injected payload hash instead of computing // payload.Hash(). FOR TESTS/BENCHMARKS ONLY: the header's payloadHash need not match the -// payload, so Block.Verify will fail. This skips a full marshal + SHA-256 of the payload. +// payload, so LaneProposal.VerifyPayload will fail. This skips a full marshal + SHA-256 of the payload. func NewBlockForTesting( lane LaneID, blockNumber BlockNumber, diff --git a/sei-tendermint/internal/autobahn/avail/block_votes_test.go b/sei-tendermint/internal/autobahn/avail/block_votes_test.go index 1c03e116d0..d4c9bfcea0 100644 --- a/sei-tendermint/internal/autobahn/avail/block_votes_test.go +++ b/sei-tendermint/internal/autobahn/avail/block_votes_test.go @@ -74,3 +74,68 @@ func TestBlockVotes_ZeroWeightNotCreditedUnderApplied(t *testing.T) { require.False(t, bv.qc.IsPresent()) require.Equal(t, 1, len(bv.byKey)) } + +// A alone reaches lane quorum; after a weight cut with the same membership, reweight drops the QC. +func TestBlockVotes_ReweightInvalidatesLaneQC(t *testing.T) { + rng := utils.TestRng() + a := types.GenSecretKey(rng) + b := types.GenSecretKey(rng) + c := types.GenSecretKey(rng) + d := types.GenSecretKey(rng) + + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 10}, time.Time{}, + utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + a.Public(): 3, b.Public(): 1, c.Public(): 1, d.Public(): 1, + })), 0) + require.Equal(t, uint64(2), ep0.Committee().LaneQuorum()) + lane := ep0.Committee().Lane(a.Public()).OrPanic("lane") + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() + + bv := newBlockVotes() + require.True(t, bv.pushVote(ep0, types.Sign(a, types.NewLaneVote(header)))) + require.True(t, bv.qc.IsPresent()) + + ep1 := types.NewEpoch(1, types.RoadRange{First: 10, Next: 20}, time.Time{}, + utils.OrPanic1(ep0.Committee().DeriveNext(map[types.PublicKey]uint64{ + a.Public(): 1, b.Public(): 1, c.Public(): 5, d.Public(): 5, + }, 1)), 0) + require.Equal(t, uint64(4), ep1.Committee().LaneQuorum()) + bv.reweight(ep1) + require.False(t, bv.qc.IsPresent()) + require.True(t, bv.header(header.Hash()).IsPresent()) +} + +// A+B are short of lane quorum; after a weight increase with the same membership, reweight forms a QC. +func TestBlockVotes_ReweightFormsLaneQC(t *testing.T) { + rng := utils.TestRng() + a := types.GenSecretKey(rng) + b := types.GenSecretKey(rng) + c := types.GenSecretKey(rng) + d := types.GenSecretKey(rng) + + ep0 := types.NewEpoch(0, types.RoadRange{First: 0, Next: 10}, time.Time{}, + utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + a.Public(): 1, b.Public(): 1, c.Public(): 5, d.Public(): 5, + })), 0) + require.Equal(t, uint64(4), ep0.Committee().LaneQuorum()) + lane := ep0.Committee().Lane(a.Public()).OrPanic("lane") + header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() + vote := func(sk types.SecretKey) *types.Signed[*types.LaneVote] { + return types.Sign(sk, types.NewLaneVote(header)) + } + + bv := newBlockVotes() + require.True(t, bv.pushVote(ep0, vote(a))) + require.True(t, bv.pushVote(ep0, vote(b))) + require.False(t, bv.qc.IsPresent()) + + ep1 := types.NewEpoch(1, types.RoadRange{First: 10, Next: 20}, time.Time{}, + utils.OrPanic1(ep0.Committee().DeriveNext(map[types.PublicKey]uint64{ + a.Public(): 5, b.Public(): 5, c.Public(): 1, d.Public(): 1, + }, 1)), 0) + require.Equal(t, uint64(4), ep1.Committee().LaneQuorum()) + bv.reweight(ep1) + qc, ok := bv.qc.Get() + require.True(t, ok) + require.Equal(t, header.Hash(), qc.Header().Hash()) +} diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 8b4aed36ed..425973ab81 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -345,9 +345,12 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos } lane := h.Lane() n := h.BlockNumber() + if err := types.VerifyLaneProposalPayloadAndSignature(p); err != nil { + return err + } for inner, ctrl := range s.inner.Lock() { if !laneAcceptedUnder(inner, func(ep *types.Epoch) bool { - return laneProposalAcceptedByEpoch(ep, p) + return p.Msg().VerifyCommitteeMembership(ep.Committee()) == nil }) { return nil } @@ -403,6 +406,9 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote h := vote.Msg().Header() lane := h.Lane() n := h.BlockNumber() + if err := vote.VerifySignature(); err != nil { + return fmt.Errorf("VerifySignature(): %w", err) + } for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { q, ok := inner.votes[lane] @@ -420,13 +426,13 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote if n < q.first { return nil } - applied := inner.epoch.Load() // TODO: accept future-epoch joiner votes. if !laneAcceptedUnder(inner, func(ep *types.Epoch) bool { - return laneVoteAcceptedByEpoch(ep, vote) + return laneVoteCommitteeOK(ep, vote) }) { return nil } + applied := inner.epoch.Load() for q.next <= n { q.pushBack(newBlockVotes()) } @@ -437,16 +443,10 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return nil } -// laneVoteAcceptedByEpoch reports whether vote verifies under ep's committee. -func laneVoteAcceptedByEpoch(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { - c := ep.Committee() - return vote.Msg().Verify(c) == nil && vote.VerifySig(c) == nil -} - -// laneProposalAcceptedByEpoch reports whether p verifies under ep's committee. -func laneProposalAcceptedByEpoch(ep *types.Epoch, p *types.Signed[*types.LaneProposal]) bool { +// laneVoteCommitteeOK reports whether the vote's header lane and signer are in ep's committee. +func laneVoteCommitteeOK(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { c := ep.Committee() - return p.Msg().Verify(c) == nil && p.VerifySig(c) == nil + return vote.Msg().Verify(c) == nil && c.HasReplica(vote.Key()) } // laneAcceptedUnder reports whether accept holds for the applied epoch, or for diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 72c5ce763a..9d44ff823e 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -690,10 +690,10 @@ func TestHeaders_WaitsForPrevEpochLaneVote(t *testing.T) { return fmt.Errorf("anchor epoch = %d, want < %d", ae.EpochIndex(), epLeave.EpochIndex()) } } - if laneVoteAcceptedByEpoch(epLeave, leaverVote) { + if laneVoteCommitteeOK(epLeave, leaverVote) { return fmt.Errorf("leaver must fail under applied") } - if !laneVoteAcceptedByEpoch(ep0, leaverVote) { + if !laneVoteCommitteeOK(ep0, leaverVote) { return fmt.Errorf("leaver must pass under anchor") } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 3bb857f7ab..a806821f94 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -263,8 +263,13 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { } for _, b := range suffix.Blocks { entry := inner.qcs[b.Number] - if err := b.Block.Verify(entry.epoch.Committee()); err != nil { - return nil, fmt.Errorf("verify block %d from BlockDB: %w", b.Number, err) + c := entry.epoch.Committee() + prop := types.NewLaneProposal(b.Block) + if err := prop.VerifyPayload(); err != nil { + return nil, fmt.Errorf("verify block %d payload from BlockDB: %w", b.Number, err) + } + if err := prop.VerifyCommitteeMembership(c); err != nil { + return nil, fmt.Errorf("verify block %d membership from BlockDB: %w", b.Number, err) } if err := inner.insertBlock(b.Number, b.Block); err != nil { return nil, fmt.Errorf("insert block %d from BlockDB: %w", b.Number, err) @@ -344,8 +349,12 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty committee := ep.Committee() for _, b := range blocks { byHash[b.Header().Hash()] = b - if err := b.Verify(committee); err != nil { - return fmt.Errorf("b.Verify(): %w", err) + prop := types.NewLaneProposal(b) + if err := prop.VerifyPayload(); err != nil { + return fmt.Errorf("VerifyPayload(): %w", err) + } + if err := prop.VerifyCommitteeMembership(committee); err != nil { + return fmt.Errorf("VerifyCommitteeMembership(): %w", err) } } // Atomically insert QC and blocks. @@ -404,8 +413,12 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block ep = inner.qcs[n].epoch } // Verify outside the lock against the epoch stashed with the QC. - if err := block.Verify(ep.Committee()); err != nil { - return fmt.Errorf("block.Verify(): %w", err) + prop := types.NewLaneProposal(block) + if err := prop.VerifyPayload(); err != nil { + return fmt.Errorf("VerifyPayload(): %w", err) + } + if err := prop.VerifyCommitteeMembership(ep.Committee()); err != nil { + return fmt.Errorf("VerifyCommitteeMembership(): %w", err) } for inner, ctrl := range s.inner.Lock() { // insertBlock may no-op if n fell into the contiguous prefix (or was diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index dacdbb3d32..e454bc17c3 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -10,6 +10,7 @@ import ( ) // EpochLength is the number of road indices per epoch. +// TODO: move on-chain when epoch length becomes configurable. const EpochLength types.RoadIndex = 108_000 // IndexForRoad returns the epoch index containing road. @@ -137,7 +138,7 @@ func (r *Registry) ActivateEpoch( } next++ } - prev := s.m[next-1] + prev := s.m[s.latest] committee, err := prev.Committee().DeriveNext(weights, next) if err != nil { return nil, err diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 40ffd79685..0a3d9e5e1d 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -192,6 +192,41 @@ func TestActivateEpoch_SkipsExistingSeeds(t *testing.T) { require.Equal(t, 1, ep.Committee().Lanes().Len()) } +func TestActivateEpoch_RejoinJoinedFromLatestNotPlaceholder(t *testing.T) { + rng := utils.TestRng() + a := types.GenSecretKey(rng) + b := types.GenSecretKey(rng) + committee := utils.OrPanic1(types.NewCommittee(map[types.PublicKey]uint64{ + a.Public(): 1, b.Public(): 1, + })) + r := utils.OrPanic1(NewRegistry(committee, 0, time.Time{})) + r.SetupInitialEpochs(utils.None[types.RoadRange]()) + + epLeave, err := r.ActivateEpoch( + map[types.PublicKey]uint64{b.Public(): 1}, + time.Time{}, r.FirstBlock(), + ) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(2), epLeave.EpochIndex()) + require.False(t, epLeave.Committee().HasReplica(a.Public())) + + // Seed a genesis-committee placeholder ahead of latest. Deriving from that + // slot would treat A as still present and keep Joined=0. + r.AdvanceIfNeeded(LastRoad(2)) + seeded, ok := r.EpochByIndex(3) + require.True(t, ok) + require.True(t, seeded.Committee().HasReplica(a.Public())) + + epJoin, err := r.ActivateEpoch( + map[types.PublicKey]uint64{a.Public(): 1, b.Public(): 1}, + time.Time{}, r.FirstBlock(), + ) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(4), epJoin.EpochIndex()) + lane := epJoin.Committee().Lane(a.Public()).OrPanic("rejoin") + require.Equal(t, types.EpochIndex(4), lane.Joined) +} + func TestWaitForEpoch_FastPathAndWait(t *testing.T) { synctest.Test(t, func(t *testing.T) { r, _ := makeRegistry(t) From c6b3f5aa8baebd34b6fef25600586a9ceb5576d6 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 18 Aug 2026 07:58:03 -0700 Subject: [PATCH 03/19] refactor(utils): move ReadOnly out of testonly.go Production types embed ReadOnly for TestEqual; keep the marker (and isReadOnly) in a non-testonly file. Co-authored-by: Cursor --- sei-tendermint/libs/utils/readonly.go | 22 ++++++++++++++++++++++ sei-tendermint/libs/utils/testonly.go | 19 ------------------- 2 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 sei-tendermint/libs/utils/readonly.go diff --git a/sei-tendermint/libs/utils/readonly.go b/sei-tendermint/libs/utils/readonly.go new file mode 100644 index 0000000000..a8566dd0c4 --- /dev/null +++ b/sei-tendermint/libs/utils/readonly.go @@ -0,0 +1,22 @@ +package utils + +import ( + "reflect" +) + +// ReadOnly marks a struct so TestEqual compares its private fields. +type ReadOnly struct{} + +// isReadOnly reports whether t embeds ReadOnly. +func isReadOnly(t reflect.Type) bool { + want := reflect.TypeFor[ReadOnly]() + if t.Kind() != reflect.Struct { + return false + } + for i := range t.NumField() { + if f := t.Field(i); f.Anonymous || f.Type == want { + return true + } + } + return false +} diff --git a/sei-tendermint/libs/utils/testonly.go b/sei-tendermint/libs/utils/testonly.go index fc5b77d7be..06f0e06aa4 100644 --- a/sei-tendermint/libs/utils/testonly.go +++ b/sei-tendermint/libs/utils/testonly.go @@ -6,7 +6,6 @@ import ( "fmt" "math/big" "math/rand" - "reflect" "time" "github.com/gogo/protobuf/proto" @@ -15,24 +14,6 @@ import ( "google.golang.org/protobuf/testing/protocmp" ) -// ReadOnly - if a struct embeds ReadOnly, -// its private fields will be compared by TestEqual. -type ReadOnly struct{} - -// isReadOnly returns true if t embeds ReadOnly. -func isReadOnly(t reflect.Type) bool { - want := reflect.TypeFor[ReadOnly]() - if t.Kind() != reflect.Struct { - return false - } - for i := range t.NumField() { - if f := t.Field(i); f.Anonymous || f.Type == want { - return true - } - } - return false -} - func cmpComparer[T any, PT interface { Cmp(b *T) int *T From 4e0a5d1747742120c5e94e7684d81a4bd9d5e0a4 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 18 Aug 2026 14:59:40 -0700 Subject: [PATCH 04/19] fix(autobahn): shard EVM txs with NextCommitEpoch, not tip verify-epoch CommitEpoch lagged the next road at idle epoch boundaries, so giga routers kept proxying to the retired committee. Publish the epoch of tip+1 once it is registered, and refresh after AdvanceIfNeeded / AppProposal replay. Co-authored-by: Cursor --- .../autobahn/types/committee_test.go | 10 +-- sei-tendermint/autobahn/types/testonly.go | 14 ++-- sei-tendermint/autobahn/types/types_test.go | 4 +- .../internal/autobahn/avail/inner_test.go | 2 +- .../internal/autobahn/avail/testonly.go | 2 +- .../internal/autobahn/consensus/inner_test.go | 6 +- .../internal/autobahn/data/state.go | 54 ++++++++++++--- .../autobahn/data/state_recovery_test.go | 17 +++++ .../internal/autobahn/data/state_test.go | 67 ++++++++++++++----- .../internal/p2p/giga_router_common.go | 7 +- .../internal/p2p/giga_router_fullnode.go | 4 +- .../internal/p2p/giga_router_fullnode_test.go | 2 +- .../internal/p2p/giga_router_validator.go | 4 +- .../p2p/giga_router_validator_test.go | 2 +- 14 files changed, 139 insertions(+), 56 deletions(-) diff --git a/sei-tendermint/autobahn/types/committee_test.go b/sei-tendermint/autobahn/types/committee_test.go index 8a357b974c..d05d7bbe1d 100644 --- a/sei-tendermint/autobahn/types/committee_test.go +++ b/sei-tendermint/autobahn/types/committee_test.go @@ -108,7 +108,7 @@ func TestLaneQCVerifyChecksWeight(t *testing.T) { func TestPrepareQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() ep, keys := makeEpoch(rng) - vote := NewPrepareVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First})) + vote := NewPrepareVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First}, ep.FirstBlock())) heavyOnly := NewPrepareQC([]*Signed[*PrepareVote]{ Sign(keys[0], vote), @@ -128,7 +128,7 @@ func TestPrepareQCVerifyChecksEpochBinding(t *testing.T) { return NewPrepareQC([]*Signed[*PrepareVote]{Sign(keys[0], NewPrepareVote(p))}) } - require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First})).Verify(ep)) + require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First}, ep.FirstBlock())).Verify(ep)) wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, ep.FirstBlock()) require.Error(t, sign(wrongEpoch).Verify(ep)) @@ -144,7 +144,7 @@ func TestCommitQCVerifyChecksEpochBinding(t *testing.T) { return NewCommitQC([]*Signed[*CommitVote]{Sign(keys[0], NewCommitVote(p))}) } - require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First})).Verify(ep)) + require.NoError(t, sign(ProposalAt(ep, View{Index: ep.RoadRange().First}, ep.FirstBlock())).Verify(ep)) wrongEpoch := newProposal(View{Index: ep.RoadRange().First, EpochIndex: ep.EpochIndex() + 1}, time.Time{}, nil, ep.FirstBlock()) require.Error(t, sign(wrongEpoch).Verify(ep)) @@ -156,7 +156,7 @@ func TestCommitQCVerifyChecksEpochBinding(t *testing.T) { func TestCommitQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() ep, keys := makeEpoch(rng) - vote := NewCommitVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First})) + vote := NewCommitVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First}, ep.FirstBlock())) heavyOnly := NewCommitQC([]*Signed[*CommitVote]{ Sign(keys[0], vote), @@ -172,7 +172,7 @@ func TestCommitQCVerifyChecksWeight(t *testing.T) { func TestAppQCVerifyChecksWeight(t *testing.T) { rng := utils.TestRng() ep, keys := makeEpoch(rng) - vote := NewAppVote(NewAppProposal(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First}), GenAppHash(rng))) + vote := NewAppVote(NewAppProposal(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First}, ep.FirstBlock()), GenAppHash(rng))) heavyOnly := NewAppQC([]*Signed[*AppVote]{ Sign(keys[0], vote), diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index fb6da8bb5f..82129f61d7 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -308,7 +308,7 @@ func GenEpochWithCommittee(rng utils.Rng, committee *Committee) *Epoch { // CommitQCAt creates a CommitQC at ep.RoadRange().First, signed by all keys. func CommitQCAt(ep *Epoch, keys []SecretKey) *CommitQC { - vote := NewCommitVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First})) + vote := NewCommitVote(ProposalAt(ep, View{EpochIndex: ep.EpochIndex(), Index: ep.RoadRange().First}, ep.FirstBlock())) votes := make([]*Signed[*CommitVote], len(keys)) for i, k := range keys { votes[i] = Sign(k, vote) @@ -326,15 +326,15 @@ func GenProposalAt(rng utils.Rng, view View) *Proposal { return newProposal(view, utils.GenTimestamp(rng), utils.GenSlice(rng, GenLaneRange), GlobalBlockNumber(rng.Uint64())) } -// ProposalAt returns a minimal non-empty Proposal at view, consistent with ep. -// Includes a single 1-block lane range so Proposal.Verify accepts it (empty -// tipcuts are forbidden). For tests that care about signature weight or epoch -// binding rather than real lane/app data. -func ProposalAt(ep *Epoch, view View) *Proposal { +// ProposalAt returns a minimal non-empty Proposal at view, consistent with ep, +// starting at globalFirst. Includes a single 1-block lane range so +// Proposal.Verify accepts it (empty tipcuts are forbidden). For tests that care +// about signature weight or epoch binding rather than real lane/app data. +func ProposalAt(ep *Epoch, view View, globalFirst GlobalBlockNumber) *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))}, ep.FirstBlock()) + return newProposal(view, time.Time{}, []*LaneRange{NewLaneRange(lane, 0, utils.Some(header))}, globalFirst) } // GenProposalForEpoch generates a Proposal at a specific view whose epochIndex, diff --git a/sei-tendermint/autobahn/types/types_test.go b/sei-tendermint/autobahn/types/types_test.go index ac3d9325df..478b55fa31 100644 --- a/sei-tendermint/autobahn/types/types_test.go +++ b/sei-tendermint/autobahn/types/types_test.go @@ -148,7 +148,7 @@ func TestNewTimeoutQC_MixedPrepareQCs(t *testing.T) { ep := NewEpoch(GenEpochIndex(rng), OpenRoadRange(), utils.GenTimestamp(rng), committee, GlobalBlockNumber(rng.Uint64()%1000000)+1) view := View{Index: 0, Number: 0, EpochIndex: ep.EpochIndex()} - pqc := makePrepareQC(keys, NewPrepareVote(ProposalAt(ep, view))) + pqc := makePrepareQC(keys, NewPrepareVote(ProposalAt(ep, view, ep.FirstBlock()))) // Only keys[0] carries the PrepareQC; the rest carry None. votes := make([]*FullTimeoutVote, len(keys)) @@ -202,7 +202,7 @@ func TestTimeoutQCVerify_HighestPrepareQCSelected(t *testing.T) { makePQCAt := func(vn ViewNumber) *PrepareQC { pView := View{Index: 0, Number: vn, EpochIndex: ep.EpochIndex()} - return makePrepareQC(keys, NewPrepareVote(ProposalAt(ep, pView))) + return makePrepareQC(keys, NewPrepareVote(ProposalAt(ep, pView, ep.FirstBlock()))) } // keys[0] has PrepareQC at view number 2, keys[1] at 4, rest None. diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index e6d99ed888..aecfb4bd4d 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -271,7 +271,7 @@ func TestNextInstallableEpoch_BoundaryTipUsesDataAppQC(t *testing.T) { last := epoch.LastRoad(0) prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}))), + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}, ep0.FirstBlock()))), }) qcLast := types.BuildCommitQC(ep0, keys, utils.Some(prev), nil) require.Equal(t, last, qcLast.Index()) diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index d946336954..afa7553169 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -102,7 +102,7 @@ func setRoadAppQC(s *State, idx types.RoadIndex, appQC *types.AppQC) { func tipLink(ep *types.Epoch, key types.SecretKey, idx types.RoadIndex) *types.CommitQC { return types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(key, types.NewCommitVote(types.ProposalAt(ep, types.View{Index: idx, Number: 0}))), + types.Sign(key, types.NewCommitVote(types.ProposalAt(ep, types.View{Index: idx, Number: 0}, ep.FirstBlock()))), }) } diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 6edc787f3d..09d5941bba 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -152,7 +152,7 @@ func TestNewInner_RejectsWALAheadOfSpec(t *testing.T) { last := epoch.LastRoad(0) prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}))), + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}, ep0.FirstBlock()))), }) qcLast := types.BuildCommitQC(ep0, keys, utils.Some(prev), nil) require.Equal(t, last, qcLast.Index()) @@ -184,7 +184,7 @@ func TestNewInner_EqualTipKeepsVotes(t *testing.T) { last := epoch.LastRoad(0) prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}))), + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep0, types.View{Index: last - 1, Number: 0}, ep0.FirstBlock()))), }) qcLast := types.BuildCommitQC(ep0, keys, utils.Some(prev), nil) @@ -1147,7 +1147,7 @@ func newConsensusState(t *testing.T, registry *epoch.Registry, key types.SecretK func commitQCAtRoad(ep *types.Epoch, keys []types.SecretKey, idx types.RoadIndex) *types.CommitQC { parent := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ - types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep, types.View{Index: idx - 1, Number: 0}))), + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep, types.View{Index: idx - 1, Number: 0}, ep.FirstBlock()))), }) qc := types.BuildCommitQC(ep, keys, utils.Some(parent), nil) if qc.Proposal().Index() != idx { diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index a806821f94..14157900d1 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -60,9 +60,31 @@ type inner struct { // Anchor represents the highest fully processed row: // CommitQC, Blocks, AppProposal, AppQC present and persisted. anchor utils.AtomicSend[utils.Option[Anchor]] - // commitEpoch is the verify-epoch of the latest admitted CommitQC - // (genesis LatestEpoch when none yet). May lead AppQC/Anchor. - commitEpoch utils.AtomicSend[*types.Epoch] + // nextCommitRoad is one past the latest admitted CommitQC. None until one is admitted. + nextCommitRoad utils.Option[types.RoadIndex] + // nextCommitEpoch covers nextCommitRoad once that epoch is registered; otherwise + // the previous publish stands (genesis epoch 0 before any CommitQC). + nextCommitEpoch utils.AtomicSend[*types.Epoch] +} + +func (i *inner) admitCommitRoad(registry *epoch.Registry, road types.RoadIndex) { + if cur, ok := i.nextCommitRoad.Get(); ok && road < cur { + return // a later QC already moved the cursor + } + i.nextCommitRoad = utils.Some(road + 1) + i.publishNextCommitEpoch(registry) +} + +func (i *inner) publishNextCommitEpoch(registry *epoch.Registry) { + road, ok := i.nextCommitRoad.Get() + if !ok { + return + } + ep, err := registry.EpochAt(road) + if err != nil { + return + } + i.nextCommitEpoch.Store(ep) } // insertQC verifies and inserts a FullCommitQC into the inner state. @@ -87,7 +109,7 @@ func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error i.qcs[i.nextQC] = qcEntry{qc: qc, epoch: e} i.nextQC++ } - i.commitEpoch.Store(e) + i.admitCommitRoad(registry, qc.QC().Proposal().Index()) return nil } @@ -241,6 +263,11 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { NextAppQC: firstBlock, NextBlock: firstBlock, }) + // Empty-chain default; loaded QCs overwrite via admitCommitRoad. + genesis, ok := cfg.Registry.EpochByIndex(0) + if !ok { + return nil, fmt.Errorf("missing genesis epoch") + } inner := &inner{ qcs: map[types.GlobalBlockNumber]qcEntry{}, blocks: map[types.GlobalBlockNumber]*types.Block{}, @@ -254,7 +281,7 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { nextQC: status.First, persisted: status, anchor: utils.NewAtomicSend(utils.None[Anchor]()), - commitEpoch: utils.NewAtomicSend(cfg.Registry.LatestEpoch()), + nextCommitEpoch: utils.NewAtomicSend(genesis), } for _, qc := range suffix.CommitQCs { if err := inner.insertQC(cfg.Registry, qc); err != nil { @@ -282,6 +309,9 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { if err := inner.insertAppProposal(appProposal); err != nil { return nil, fmt.Errorf("load AppProposal from BlockDB: %w", err) } + // Match PushAppHash: do not rely only on SetupInitialEpochs for NextCommitEpoch. + cfg.Registry.AdvanceIfNeeded(appProposal.RoadIndex()) + inner.publishNextCommitEpoch(cfg.Registry) } for _, appQC := range suffix.AppQCs { if err := inner.insertAppQC(appQC); err != nil { @@ -364,7 +394,7 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty inner.qcs[inner.nextQC] = qcEntry{qc: qc, epoch: ep} inner.nextQC += 1 } - inner.commitEpoch.Store(ep) + inner.admitCommitRoad(s.cfg.Registry, qc.QC().Proposal().Index()) ctrl.Updated() } if len(byHash) > 0 { @@ -668,6 +698,8 @@ func (s *State) PushAppHash(ctx context.Context, n types.GlobalBlockNumber, hash // ConsensusSpec withholds the view after LastRoad(N+1) until this fires // again. s.cfg.Registry.AdvanceIfNeeded(p.Index()) + // Idle boundary: no further CommitQC will republish after registration. + inner.publishNextCommitEpoch(s.cfg.Registry) ctrl.Updated() // CRITICAL: We need to persist AppHash before we return and start executing the next block, // otherwise we lose the apphash on restart. @@ -756,12 +788,12 @@ func (s *State) Anchor() utils.AtomicRecv[utils.Option[Anchor]] { panic("unreachable") } -// CommitEpoch returns the verify-epoch of the latest admitted CommitQC, or the -// genesis epoch when none has been admitted. It may lead AppQC/Anchor. -// Used by giga EVM tx sharding (EvmProxy / EvmShard). -func (s *State) CommitEpoch() utils.AtomicRecv[*types.Epoch] { +// NextCommitEpoch returns the epoch covering the road after the latest admitted +// CommitQC, or genesis epoch 0 when none has been admitted. If that epoch is not +// registered yet, the previous value stands. Used by giga EVM tx sharding. +func (s *State) NextCommitEpoch() utils.AtomicRecv[*types.Epoch] { for inner := range s.inner.Lock() { - return inner.commitEpoch.Subscribe() + return inner.nextCommitEpoch.Subscribe() } panic("unreachable") } diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index 9644ccabf2..f78432cdee 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -457,3 +457,20 @@ func TestNewState_SetupInitialEpochsFromCommitQCSpan(t *testing.T) { t.Fatal("epoch 2 should not be seeded from a single epoch-0 CommitQC") } } + +func TestNewState_NextCommitEpochAtBoundaryTip(t *testing.T) { + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 3) + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok) + + qc, blocks := commitQCAtRoad(ep1, keys, epoch.LastRoad(1), ep1.FirstBlock()) + db := newTestBlockDB(t, t.TempDir()) + writeToBlockDB(t, db, []*types.FullCommitQC{qc}, [][]*types.Block{blocks}) + writeAppDataToBlockDB(t, rng, db, keys, qc) + + state := newTestState(t, &Config{Registry: registry}, db) + ep2, err := registry.EpochAt(epoch.FirstRoad(2)) + require.NoError(t, err) + require.Equal(t, ep2, state.NextCommitEpoch().Load()) +} diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index eaa7178af2..0def0d91ee 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -114,26 +114,71 @@ func pushAppQCForBlock(ctx context.Context, state *State, keys []types.SecretKey return state.PushAppQC(ctx, TestAppQC(keys, vote.Proposal())) } -func TestCommitEpoch_TracksLatestCommitQC(t *testing.T) { +func commitQCAtRoad( + ep *types.Epoch, + keys []types.SecretKey, + road types.RoadIndex, + globalFirst types.GlobalBlockNumber, +) (*types.FullCommitQC, []*types.Block) { + proposal := types.ProposalAt(ep, types.View{Index: road, Number: 0}, globalFirst) + block := types.NewBlock(ep.Committee().Lanes().At(0), 0, types.BlockHeaderHash{}, &types.Payload{}) + votes := make([]*types.Signed[*types.CommitVote], 0, len(keys)) + for _, k := range keys { + votes = append(votes, types.Sign(k, types.NewCommitVote(proposal))) + } + return types.NewFullCommitQC(types.NewCommitQC(votes), []*types.BlockHeader{block.Header()}), []*types.Block{block} +} + +func TestNextCommitEpoch_TracksNextRoadEpoch(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) + ep0, ok := registry.EpochByIndex(0) + require.True(t, ok) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) - require.Equal(t, registry.LatestEpoch(), state.CommitEpoch().Load()) + require.Equal(t, ep0, state.NextCommitEpoch().Load()) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, ep0, keys, utils.None[*types.CommitQC]()) if err := state.PushQC(ctx, qc, blocks); err != nil { return err } - require.Equal(t, registry.LatestEpoch(), state.CommitEpoch().Load()) + require.Equal(t, ep0, state.NextCommitEpoch().Load()) return nil })) } +func TestNextCommitEpoch_AdvancesAtIdleEpochBoundary(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + registry, keys := epoch.GenRegistry(rng, 3) + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok) + state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) + + qcMid, blocksMid := commitQCAtRoad(ep1, keys, epoch.FirstRoad(1), ep1.FirstBlock()) + require.NoError(t, state.PushQC(ctx, qcMid, blocksMid)) + require.Equal(t, ep1, state.NextCommitEpoch().Load(), "mid-epoch next road is still in epoch 1") + grMid := qcMid.QC().GlobalRange() + require.NoError(t, pushAppHashesRunning(ctx, state, rng, grMid.First, grMid.Next)) + require.Equal(t, ep1, state.NextCommitEpoch().Load(), "mid-epoch AppHash must not seed epoch 2") + + qcLast, blocksLast := commitQCAtRoad(ep1, keys, epoch.LastRoad(1), grMid.Next) + require.NoError(t, state.PushQC(ctx, qcLast, blocksLast)) + _, err := registry.EpochAt(epoch.FirstRoad(2)) + require.Error(t, err, "epoch 2 must stay unregistered until the boundary AppProposal lands") + require.Equal(t, ep1, state.NextCommitEpoch().Load(), "unregistered next epoch: previous publish stands") + + grLast := qcLast.QC().GlobalRange() + require.NoError(t, pushAppHashesRunning(ctx, state, rng, grLast.First, grLast.Next)) + ep2, err := registry.EpochAt(epoch.FirstRoad(2)) + require.NoError(t, err) + require.Equal(t, ep2, state.NextCommitEpoch().Load()) +} + func TestState(t *testing.T) { ctx := t.Context() rng := utils.TestRng() @@ -474,21 +519,11 @@ func TestPushAppHash_AdvancesRegistryAtEpochBoundary(t *testing.T) { ep := registry.LatestEpoch() require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) - // A valid QC stamped at the epoch boundary: ProposalAt finalizes one block on - // lane 0 starting at ep.FirstBlock(), which a fresh state admits since PushQC - // requires global-block contiguity, not road contiguity. block must stay - // identical to the one ProposalAt builds, or the header hashes disagree. - proposal := types.ProposalAt(ep, types.View{Index: epoch.LastRoad(0), Number: 0}) - block := types.NewBlock(ep.Committee().Lanes().At(0), 0, types.BlockHeaderHash{}, &types.Payload{}) - votes := make([]*types.Signed[*types.CommitVote], 0, len(keys)) - for _, k := range keys { - votes = append(votes, types.Sign(k, types.NewCommitVote(proposal))) - } - qc := types.NewFullCommitQC(types.NewCommitQC(votes), []*types.BlockHeader{block.Header()}) + qc, blocks := commitQCAtRoad(ep, keys, epoch.LastRoad(0), ep.FirstBlock()) if qc.QC().Proposal().Index() != epoch.LastRoad(0) { return fmt.Errorf("road = %d, want %d", qc.QC().Proposal().Index(), epoch.LastRoad(0)) } - if err := state.PushQC(ctx, qc, []*types.Block{block}); err != nil { + if err := state.PushQC(ctx, qc, blocks); err != nil { return err } if err := state.PushAppHash(ctx, qc.QC().GlobalRange().Next-1, types.GenAppHash(rng)); err != nil { diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 5b409c65fc..7262defa8a 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -41,10 +41,9 @@ type gigaRouterCommon struct { poolOut *giga.Pool[NodePublicKey, rpc.Client[giga.API]] proxies utils.RWMutex[map[atypes.PublicKey]*ethrpc.Client] app *proxy.Proxy - // commitEpoch is data.CommitEpoch() cached at construction so EvmProxy - // can Load() without taking the data lock on every call. Used for EVM - // tx sharding (Committee.EvmShard). - commitEpoch utils.AtomicRecv[*atypes.Epoch] + // nextCommitEpoch is data.NextCommitEpoch() cached at construction so + // EvmProxy can Load() without taking the data lock on every call. + nextCommitEpoch utils.AtomicRecv[*atypes.Epoch] // inboundFullnodeCount tracks live non-committee inbound block-sync // connections. Optimistic Add(1) + compare against cap; over-rejects diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index 2507d8620e..cdc7afd9e5 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -30,7 +30,7 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey, dataS cfg: cfg, key: key, data: dataState, - commitEpoch: dataState.CommitEpoch(), + nextCommitEpoch: dataState.NextCommitEpoch(), service: giga.NewFullNodeService(dataState), poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), @@ -66,7 +66,7 @@ func (r *gigaFullnodeRouter) Run(ctx context.Context) error { // EvmProxy on the fullnode always returns the shard owner's EVM RPC client. // EnableEvmProxy is a no-op here because fullnodes do not have a local mempool. func (r *gigaFullnodeRouter) EvmProxy(sender common.Address) utils.Option[*ethrpc.Client] { - return r.evmProxy(r.commitEpoch.Load().Committee().EvmShard(sender)) + return r.evmProxy(r.nextCommitEpoch.Load().Committee().EvmShard(sender)) } // runFullnodeSubscriber: pick a committee member, dial + block-sync, diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go index 833af2922b..ddcab039cc 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go @@ -101,7 +101,7 @@ func TestGigaRouter_Fullnode(t *testing.T) { returnedRemoteClients := map[*ethrpc.Client]struct{}{} for range 200 { sender := common.BytesToAddress(utils.GenBytes(rng, common.AddressLength)) - shardValidator := router.data.CommitEpoch().Load().Committee().EvmShard(sender) + shardValidator := router.data.NextCommitEpoch().Load().Committee().EvmShard(sender) expectedClient := clientByValidator[shardValidator] proxyClient, ok := router.EvmProxy(sender).Get() require.True(t, ok) diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 3757f21285..3476fbcf1b 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -45,7 +45,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey, dataSta cfg: &cfg.GigaRouterCommonConfig, key: key, data: dataState, - commitEpoch: dataState.CommitEpoch(), + nextCommitEpoch: dataState.NextCommitEpoch(), service: giga.NewService(consensusState), poolIn: giga.NewPool[NodePublicKey, rpc.Server[giga.API]](), poolOut: giga.NewPool[NodePublicKey, rpc.Client[giga.API]](), @@ -106,7 +106,7 @@ func (r *gigaValidatorRouter) EvmProxy(sender common.Address) utils.Option[*ethr if !r.cfg.EnableEvmProxy { return utils.None[*ethrpc.Client]() } - validator := r.commitEpoch.Load().Committee().EvmShard(sender) + validator := r.nextCommitEpoch.Load().Committee().EvmShard(sender) if r.validatorKey == validator { return utils.None[*ethrpc.Client]() } diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index e9ec07668a..9dce06a5cd 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -309,7 +309,7 @@ func TestGigaRouter_EvmProxy(t *testing.T) { seenDisconnected := false for range 400 { sender := common.BytesToAddress(utils.GenBytes(rng, common.AddressLength)) - shardValidator := router.data.CommitEpoch().Load().Committee().EvmShard(sender) + shardValidator := router.data.NextCommitEpoch().Load().Committee().EvmShard(sender) proxyClient, ok := router.EvmProxy(sender).Get() From e6c022d144e1c7e2fcd65a002a56b143f5c35e5c Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 18 Aug 2026 15:27:35 -0700 Subject: [PATCH 05/19] fix(autobahn): optional ConsensusSpec tip and hard-error missing catch-up epochs Genesis has no CommitQC, so ConsensusSpec always carries Epoch (and FirstBlock) with an Option tip and consensus no longer needs the registry to restore. Restart catch-up errors when seal+prune leashes are met but the next epoch is unregistered instead of soft-stopping. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 7 +- .../internal/autobahn/avail/inner.go | 44 ++++++------ .../internal/autobahn/avail/inner_test.go | 70 +++++++++++++------ .../internal/autobahn/avail/state.go | 5 +- .../internal/autobahn/avail/state_test.go | 8 +-- .../internal/autobahn/consensus/inner.go | 39 +++-------- .../internal/autobahn/consensus/inner_test.go | 35 +++++----- .../internal/autobahn/consensus/state.go | 12 +--- 8 files changed, 114 insertions(+), 106 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 5c3e562c1c..72996397c3 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -126,10 +126,11 @@ func (v View) Next() View { } // ConsensusSpec is the durable CommitQC tip paired with the epoch of the view -// that follows it. Avail publishes Option[ConsensusSpec] (None until a tip -// exists); consensus installs Some values verbatim. +// that follows it. CommitQC is None before the first tip; until then Epoch is +// genesis epoch 0 (and FirstBlock is the next global block). Consensus installs +// a spec verbatim. type ConsensusSpec struct { - CommitQC *CommitQC + CommitQC utils.Option[*CommitQC] Epoch *Epoch } diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 2133220115..9585baebd5 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -13,7 +13,7 @@ import ( // inner holds roads and per-LaneID block/vote maps. type inner struct { persistedCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] // latest persisted CommitQC - consensusSpec utils.AtomicSend[utils.Option[types.ConsensusSpec]] + consensusSpec utils.AtomicSend[types.ConsensusSpec] roads *queue[types.RoadIndex, *road] // epoch is the applied (next-CommitQC) epoch. installEpoch is the sole @@ -52,9 +52,13 @@ type loadedState struct { func newInner(ds *data.State, loaded *loadedState) (*inner, error) { start := ds.Registry().LatestEpoch() + genesis, ok := ds.Registry().EpochByIndex(0) + if !ok { + return nil, fmt.Errorf("genesis epoch 0 not registered") + } i := &inner{ persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), - consensusSpec: utils.NewAtomicSend(utils.None[types.ConsensusSpec]()), + consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: genesis}), roads: newQueue[types.RoadIndex, *road](), epoch: utils.NewAtomicSend(start), blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, @@ -134,12 +138,8 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { } // Restart catch-up: install every epoch the durable leashes already allow. // The live path (runEpochAdvance) installs one waited-for epoch at a time. - for { - next, ok := i.nextInstallableEpoch(ds).Get() - if !ok { - break - } - i.installEpoch(next) + if err := i.installReadyEpochs(ds); err != nil { + return nil, err } i.refreshConsensusSpec() return i, nil @@ -170,18 +170,19 @@ func (i *inner) leashesMet() bool { return ok && ae.EpochIndex() >= ep.EpochIndex() } -// nextInstallableEpoch returns the next registry epoch when the applied epoch -// is sealed, its prune leash is met, and the execution leash is met (next epoch -// registered). -func (i *inner) nextInstallableEpoch(ds *data.State) utils.Option[*types.Epoch] { - if !i.leashesMet() { - return utils.None[*types.Epoch]() - } - next, ok := ds.Registry().EpochByIndex(i.epoch.Load().EpochIndex() + 1) - if !ok { - return utils.None[*types.Epoch]() +// installReadyEpochs installs every epoch whose seal and prune leashes are +// already met. A missing next registry epoch in that state is an invariant +// violation (execution leash should already have registered it). +func (i *inner) installReadyEpochs(ds *data.State) error { + for i.leashesMet() { + nextIdx := i.epoch.Load().EpochIndex() + 1 + next, ok := ds.Registry().EpochByIndex(nextIdx) + if !ok { + return fmt.Errorf("epoch %d not registered with seal+prune leashes met", nextIdx) + } + i.installEpoch(next) } - return utils.Some(next) + return nil } // refreshConsensusSpec publishes ConsensusSpec for the durable tip, paired with @@ -194,7 +195,8 @@ func (i *inner) nextInstallableEpoch(ds *data.State) utils.Option[*types.Epoch] // not be handed a predecessor of the tip it holds: installing it would roll the // view backwards and discard that view's votes. func (i *inner) refreshConsensusSpec() { - cqc, ok := i.persistedCommitQC.Load().Get() + tip := i.persistedCommitQC.Load() + cqc, ok := tip.Get() if !ok { return } @@ -206,7 +208,7 @@ func (i *inner) refreshConsensusSpec() { if !ok { return } - i.consensusSpec.Store(utils.Some(types.ConsensusSpec{CommitQC: cqc, Epoch: ep})) + i.consensusSpec.Store(types.ConsensusSpec{CommitQC: tip, Epoch: ep}) } func (i *inner) epochForRoad(road types.RoadIndex) utils.Option[*types.Epoch] { diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index aecfb4bd4d..d8dc2bf616 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -223,12 +223,12 @@ func TestAddLane_ReportsNewLaneForEachMembershipPeriod(t *testing.T) { require.True(t, i.addLane(types.LaneID{Validator: a.Public(), Joined: 3})) } -// TestNextInstallableEpoch_BoundaryTipUsesDataAppQC: tip at LastRoad(0) with +// TestInstallReadyEpochs_BoundaryTipUsesDataAppQC: tip at LastRoad(0) with // applied floored to 0 (restart), data's Anchor already covers epoch 0, registry // has epoch 1 → install walks to 1 so ConsensusSpec republishes the tip. // This is the avail half of the blind-Spec restore invariant: consensus may // refuse to start if Spec stays behind a WAL tip at LastRoad(0) after catch-up. -func TestNextInstallableEpoch_BoundaryTipUsesDataAppQC(t *testing.T) { +func TestInstallReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) @@ -278,7 +278,7 @@ func TestNextInstallableEpoch_BoundaryTipUsesDataAppQC(t *testing.T) { i := &inner{ persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), - consensusSpec: utils.NewAtomicSend(utils.None[types.ConsensusSpec]()), + consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep0}), roads: newQueue[types.RoadIndex, *road](), epoch: utils.NewAtomicSend(ep0), anchorEpoch: utils.Some(anchor.Epoch), @@ -297,25 +297,54 @@ func TestNextInstallableEpoch_BoundaryTipUsesDataAppQC(t *testing.T) { require.False(t, i.roads.q[last].appQC.IsPresent(), "road AppQC empty; prune leash is the Anchor") require.True(t, i.leashesMet()) - require.True(t, i.nextInstallableEpoch(ds).IsPresent()) - require.Equal(t, types.EpochIndex(1), i.nextInstallableEpoch(ds).OrPanic("installable").EpochIndex()) - - for { - next, ok := i.nextInstallableEpoch(ds).Get() - if !ok { - break - } - i.installEpoch(next) - } - i.refreshConsensusSpec() + require.NoError(t, i.installReadyEpochs(ds)) require.Equal(t, ep1.EpochIndex(), i.epoch.Load().EpochIndex()) - spec, ok := i.consensusSpec.Load().Get() + spec := i.consensusSpec.Load() + cqc, ok := spec.CommitQC.Get() require.True(t, ok) - require.Equal(t, last, spec.CommitQC.Index(), "must not walk tip back to LastRoad(0)-1") + require.Equal(t, last, cqc.Index(), "must not walk tip back to LastRoad(0)-1") require.Equal(t, types.EpochIndex(1), spec.Epoch.EpochIndex()) } +func TestInstallReadyEpochs_MissingNextEpochErrors(t *testing.T) { + rng := utils.TestRng() + // Fresh registry has epochs 0 and 1; seal epoch 1 so the next lookup is 2. + registry, keys := epoch.GenRegistry(rng, 3) + ep1, ok := registry.EpochByIndex(1) + require.True(t, ok) + _, err := registry.EpochAt(epoch.FirstRoad(2)) + require.Error(t, err) + + last := epoch.LastRoad(1) + prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ + types.Sign(keys[0], types.NewCommitVote(types.ProposalAt(ep1, types.View{Index: last - 1, Number: 0}, ep1.FirstBlock()))), + }) + qcLast := types.BuildCommitQC(ep1, keys, utils.Some(prev), nil) + require.Equal(t, last, qcLast.Index()) + + i := &inner{ + persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), + consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep1}), + roads: newQueue[types.RoadIndex, *road](), + epoch: utils.NewAtomicSend(ep1), + anchorEpoch: utils.Some(ep1), + blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, + votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, + nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, + } + for lane := range ep1.Committee().Lanes().All() { + i.addLane(lane) + } + i.roads.first = last + i.roads.next = last + i.roads.pushBack(newRoad(qcLast, ep1)) + i.persistedCommitQC.Store(utils.Some(qcLast)) + + require.True(t, i.leashesMet()) + require.Error(t, i.installReadyEpochs(newTestDataState(&data.Config{Registry: registry}))) +} + // TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied: the durable tip // sits on LastRoad(0) while applied is still epoch 0, and the tip's predecessor is // retained. The spec must be withheld rather than published at that predecessor — @@ -338,7 +367,7 @@ func TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied(t *testing.T i := &inner{ persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), - consensusSpec: utils.NewAtomicSend(utils.None[types.ConsensusSpec]()), + consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep0}), roads: newQueue[types.RoadIndex, *road](), epoch: utils.NewAtomicSend(ep0), blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, @@ -352,11 +381,12 @@ func TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied(t *testing.T i.persistedCommitQC.Store(utils.Some(qcLast)) i.refreshConsensusSpec() - require.False(t, i.consensusSpec.Load().IsPresent(), "spec must be withheld, not published at the predecessor") + require.False(t, i.consensusSpec.Load().CommitQC.IsPresent(), "spec must be withheld, not published at the predecessor") i.installEpoch(ep1) - spec, ok := i.consensusSpec.Load().Get() + spec := i.consensusSpec.Load() + cqc, ok := spec.CommitQC.Get() require.True(t, ok) - require.Equal(t, last, spec.CommitQC.Index()) + require.Equal(t, last, cqc.Index()) require.Equal(t, types.EpochIndex(1), spec.Epoch.EpochIndex()) } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 425973ab81..cff71ec3a0 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -173,8 +173,9 @@ func (s *State) LastCommitQC() utils.AtomicRecv[utils.Option[*types.CommitQC]] { } // SubscribeConsensusSpec returns a receiver of the durable CommitQC tip paired -// with the epoch governing the view that follows it. None until a tip exists. -func (s *State) SubscribeConsensusSpec() utils.AtomicRecv[utils.Option[types.ConsensusSpec]] { +// with the epoch governing the view that follows it. CommitQC is None before +// the first tip; until then Epoch is genesis epoch 0. +func (s *State) SubscribeConsensusSpec() utils.AtomicRecv[types.ConsensusSpec] { for inner := range s.inner.Lock() { return inner.consensusSpec.Subscribe() } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 9d44ff823e..086dbadf21 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -976,14 +976,14 @@ func TestMarkCommitQCsPersisted_RefreshesSpecWhileEpochAdvanceWaitsForRegistry(t go func() { advanceErr = f.state.runEpochAdvance(ctx) }() synctest.Wait() require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex(), "parked on WaitForEpoch(M+1)") - got, ok := spec.Load().Get() + got, ok := spec.Load().CommitQC.Get() require.True(t, ok) - require.Equal(t, qcA.Index(), got.CommitQC.Index()) + require.Equal(t, qcA.Index(), got.Index()) f.state.markCommitQCsPersisted(qcB) - got, ok = spec.Load().Get() + got, ok = spec.Load().CommitQC.Get() require.True(t, ok) - require.Equal(t, qcB.Index(), got.CommitQC.Index()) + require.Equal(t, qcB.Index(), got.Index()) require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex(), "still waiting on registry") cancel() diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 36dec461c1..99eb78539f 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -81,7 +81,6 @@ import ( "fmt" "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/pb" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/seilog" @@ -105,13 +104,11 @@ func (i inner) View() types.View { // newInner restores consensus state from avail's ConsensusSpec. The tip CommitQC // and next-view epoch always come from the spec. The WAL is kept only for -// same-view votes / TimeoutQC / PrepareQC when its tip matches the spec. -// specOpt is None at genesis (no durable tip yet). Returns +// same-view votes / TimeoutQC / PrepareQC when its tip matches the spec. Returns // ErrAvailBehindConsensus when the WAL tip is ahead of the spec. func newInner( loaded utils.Option[*pb.PersistedInner], - specOpt utils.Option[types.ConsensusSpec], - registry *epoch.Registry, + spec types.ConsensusSpec, ) (inner, error) { var persisted persistedInner if p, ok := loaded.Get(); ok { @@ -123,32 +120,16 @@ func newInner( } persistedViewIdx := types.NextIndexOpt(persisted.CommitQC) - spec, hasSpec := specOpt.Get() - specViewIdx := types.RoadIndex(0) - if hasSpec { - specViewIdx = spec.CommitQC.Index() + 1 - } + specViewIdx := types.NextIndexOpt(spec.CommitQC) if persistedViewIdx > specViewIdx { return inner{}, fmt.Errorf("%w: persisted tip %d > ConsensusSpec tip %d", ErrAvailBehindConsensus, persistedViewIdx, specViewIdx) } - if !hasSpec { // genesis: no ConsensusSpec (and thus no WAL CommitQC) - ep, ok := registry.EpochByIndex(0) - if !ok { - panic("genesis epoch 0 not registered") - } - if err := persisted.validate(ep); err != nil { - return inner{}, err - } - logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) - return inner{persistedInner: persisted, epoch: ep}, nil - } - if specViewIdx == persistedViewIdx { // Same tip: take CommitQC from the spec; keep WAL votes / view QCs. out := persisted - out.CommitQC = utils.Some(spec.CommitQC) + out.CommitQC = spec.CommitQC if err := out.validate(spec.Epoch); err != nil { return inner{}, err } @@ -156,24 +137,26 @@ func newInner( return inner{persistedInner: out, epoch: spec.Epoch}, nil } - out := persistedInner{CommitQC: utils.Some(spec.CommitQC)} + out := persistedInner{CommitQC: spec.CommitQC} logger.Info("restored consensus state from avail ConsensusSpec", "state", innerProtoConv.Encode(&out)) return inner{persistedInner: out, epoch: spec.Epoch}, nil } // pushSpecFromAvail installs avail's ConsensusSpec tip and clears per-view state. +// Specs that do not advance the view are ignored, which covers the tipless spec +// published before the first CommitQC. func (s *State) pushSpecFromAvail(spec types.ConsensusSpec) error { - qc := spec.CommitQC - if qc.Proposal().Index() < s.innerRecv.Load().View().Index { + specViewIdx := types.NextIndexOpt(spec.CommitQC) + if specViewIdx <= s.innerRecv.Load().View().Index { return nil } for iSend := range s.inner.Lock() { i := iSend.Load() - if qc.Proposal().Index() < i.View().Index { + if specViewIdx <= i.View().Index { return nil } // CommitQC advances to new index; clear all state for new view. - iSend.Store(inner{persistedInner: persistedInner{CommitQC: utils.Some(spec.CommitQC)}, epoch: spec.Epoch}) + iSend.Store(inner{persistedInner: persistedInner{CommitQC: spec.CommitQC}, epoch: spec.Epoch}) } return nil } diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 09d5941bba..166033a03a 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -79,7 +79,7 @@ func loadInner(t *testing.T, dir string, registry *epoch.Registry, keys []types. } } } - return newInner(persisted, av.SubscribeConsensusSpec().Load(), registry) + return newInner(persisted, av.SubscribeConsensusSpec().Load()) } // alignAvailToTip pushes CommitQCs 0..tip.Index() through avail and waits until @@ -130,7 +130,7 @@ func TestNewInnerEmpty(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 1) _, av := newTestAvail(t, registry, keys[0]) - i, err := newInner(utils.None[*pb.PersistedInner](), av.SubscribeConsensusSpec().Load(), registry) + i, err := newInner(utils.None[*pb.PersistedInner](), av.SubscribeConsensusSpec().Load()) require.NoError(t, err) require.False(t, i.PrepareVote.IsPresent(), "prepareVote should be None") require.False(t, i.CommitVote.IsPresent(), "commitVote should be None") @@ -157,7 +157,7 @@ func TestNewInner_RejectsWALAheadOfSpec(t *testing.T) { qcLast := types.BuildCommitQC(ep0, keys, utils.Some(prev), nil) require.Equal(t, last, qcLast.Index()) - // Spec still withheld (None): next-view epoch not applied after a floor. + // Spec tip still None (genesis-shaped stand-in for a withheld tip). view := types.View{Index: last + 1, Number: 0, EpochIndex: 1} proposal := types.GenProposalForEpoch(rng, ep1, view) vote := types.Sign(keys[0], types.NewPrepareVote(proposal)) @@ -166,7 +166,8 @@ func TestNewInner_RejectsWALAheadOfSpec(t *testing.T) { PrepareVote: utils.Some(vote), } - _, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), utils.None[types.ConsensusSpec](), registry) + genesis := types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep0} + _, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), genesis) require.ErrorIs(t, err, ErrAvailBehindConsensus) } @@ -195,9 +196,9 @@ func TestNewInner_EqualTipKeepsVotes(t *testing.T) { CommitQC: utils.Some(qcLast), PrepareVote: utils.Some(vote), } - spec := types.ConsensusSpec{CommitQC: qcLast, Epoch: ep1} + spec := types.ConsensusSpec{CommitQC: utils.Some(qcLast), Epoch: ep1} - i, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), utils.Some(spec), registry) + i, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), spec) require.NoError(t, err) require.Equal(t, last+1, i.View().Index) require.Equal(t, types.EpochIndex(1), i.epoch.EpochIndex()) @@ -238,33 +239,31 @@ func TestRestore_BoundaryCatchUpSpecCoversWAL(t *testing.T) { }); err != nil { return fmt.Errorf("wait durable tip: %w", err) } - got, err := av.SubscribeConsensusSpec().Wait(ctx, func(o utils.Option[types.ConsensusSpec]) bool { - sp, ok := o.Get() - return ok && sp.CommitQC.Index() >= last && sp.Epoch.EpochIndex() >= 1 + got, err := av.SubscribeConsensusSpec().Wait(ctx, func(sp types.ConsensusSpec) bool { + cqc, ok := sp.CommitQC.Get() + return ok && cqc.Index() >= last && sp.Epoch.EpochIndex() >= 1 }) if err != nil { return fmt.Errorf("wait ConsensusSpec: %w", err) } - sp, ok := got.Get() - if !ok { - return fmt.Errorf("ConsensusSpec missing after catch-up") - } - spec = sp + spec = got return nil })) - require.Equal(t, last, spec.CommitQC.Index(), "catch-up must republish the boundary tip, not withhold") + tip, ok := spec.CommitQC.Get() + require.True(t, ok) + require.Equal(t, last, tip.Index(), "catch-up must republish the boundary tip, not withhold") require.Equal(t, types.EpochIndex(1), spec.Epoch.EpochIndex()) view := types.View{Index: last + 1, Number: 0, EpochIndex: 1} proposal := types.GenProposalForEpoch(rng, spec.Epoch, view) vote := types.Sign(keys[0], types.NewPrepareVote(proposal)) persisted := persistedInner{ - CommitQC: utils.Some(spec.CommitQC), + CommitQC: spec.CommitQC, PrepareVote: utils.Some(vote), } - i, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), utils.Some(spec), registry) + i, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), spec) require.NoError(t, err) require.Equal(t, last+1, i.View().Index) require.Equal(t, types.EpochIndex(1), i.epoch.EpochIndex()) @@ -1170,7 +1169,7 @@ func TestPushCommitQC_RotatesEpochAtBoundary(t *testing.T) { // Avail resolves the next-view epoch; pushSpecFromAvail installs it verbatim. ep1, err := registry.EpochAt(epoch.FirstRoad(1)) require.NoError(t, err) - require.NoError(t, s.pushSpecFromAvail(types.ConsensusSpec{CommitQC: qc, Epoch: ep1})) + require.NoError(t, s.pushSpecFromAvail(types.ConsensusSpec{CommitQC: utils.Some(qc), Epoch: ep1})) got := s.innerRecv.Load() require.Equal(t, types.EpochIndex(1), got.epoch.EpochIndex()) require.Equal(t, epoch.FirstRoad(1), got.View().Index) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index c55a42c165..db89dda261 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -106,11 +106,7 @@ func newState( return nil, fmt.Errorf("avail.NewState: %w", err) } - initialInner, err := newInner( - persistedData, - availState.SubscribeConsensusSpec().Load(), - data.Registry(), - ) + initialInner, err := newInner(persistedData, availState.SubscribeConsensusSpec().Load()) if err != nil { _ = availState.Close() return nil, fmt.Errorf("newInner: %w", err) @@ -319,11 +315,7 @@ func (s *State) Run(ctx context.Context) error { // We pull the tip back from "avail" for dissemination. This ensures we // only advance on CommitQCs that avail has verified, logged, and paired // with the epoch of the next view — consensus resolves no epochs itself. - return s.avail.SubscribeConsensusSpec().Iter(ctx, func(ctx context.Context, specOpt utils.Option[types.ConsensusSpec]) error { - spec, ok := specOpt.Get() - if !ok { - return nil - } + return s.avail.SubscribeConsensusSpec().Iter(ctx, func(ctx context.Context, spec types.ConsensusSpec) error { return s.pushSpecFromAvail(spec) }) }) From 0f2077303d3b716cc9aa86df8d1688d1e9f6d1be Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 18 Aug 2026 15:38:45 -0700 Subject: [PATCH 06/19] refactor(autobahn): split Verify integrity from committee membership Keep LaneProposal/Block.Verify and Signed.VerifySig as integrity/crypto only; check HasReplica/HasLane at call sites. Data blocks rely on FullCommitQC header match instead of a separate membership pass. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/block.go | 8 +++ .../autobahn/types/lane_proposal.go | 28 ++------- sei-tendermint/autobahn/types/msg.go | 17 ++--- sei-tendermint/autobahn/types/proposal.go | 5 +- sei-tendermint/autobahn/types/testonly.go | 2 +- sei-tendermint/autobahn/types/timeout.go | 10 ++- .../internal/autobahn/avail/state.go | 62 ++++++++++++------- .../internal/autobahn/avail/state_test.go | 4 +- .../autobahn/consensus/persisted_inner.go | 11 +++- .../internal/autobahn/consensus/state.go | 10 ++- .../internal/autobahn/data/state.go | 35 +++-------- 11 files changed, 99 insertions(+), 93 deletions(-) diff --git a/sei-tendermint/autobahn/types/block.go b/sei-tendermint/autobahn/types/block.go index 716683743f..99576ca025 100644 --- a/sei-tendermint/autobahn/types/block.go +++ b/sei-tendermint/autobahn/types/block.go @@ -139,6 +139,14 @@ func (b *Block) Header() *BlockHeader { return b.header } // Payload . func (b *Block) Payload() *Payload { return b.payload } +// Verify checks that the payload hashes to the header payload hash. +func (b *Block) Verify() error { + if got, want := b.payload.Hash(), b.header.payloadHash; got != want { + return fmt.Errorf("payload.Hash() = %v, want %v", got, want) + } + return nil +} + // Hash of the BlockHeader. func (h *BlockHeader) Hash() BlockHeaderHash { return BlockHeaderHash(hashable.ToHash(BlockHeaderConv.Encode(h))) diff --git a/sei-tendermint/autobahn/types/lane_proposal.go b/sei-tendermint/autobahn/types/lane_proposal.go index 4c32ad2467..d250708ee1 100644 --- a/sei-tendermint/autobahn/types/lane_proposal.go +++ b/sei-tendermint/autobahn/types/lane_proposal.go @@ -22,30 +22,10 @@ func NewLaneProposal(block *Block) *LaneProposal { // Block . func (m *LaneProposal) Block() *Block { return m.block } -// VerifyPayload checks that the payload hashes to the header payload hash. -func (m *LaneProposal) VerifyPayload() error { - b := m.block - if got, want := b.payload.Hash(), b.header.payloadHash; got != want { - return fmt.Errorf("payload.Hash() = %v, want %v", got, want) - } - return nil -} - -// VerifyCommitteeMembership checks that the proposal's lane is in the committee. -func (m *LaneProposal) VerifyCommitteeMembership(c *Committee) error { - return m.block.header.Verify(c) -} - -// VerifyLaneProposalPayloadAndSignature verifies payload hash and signature. It -// does not check committee membership. -func VerifyLaneProposalPayloadAndSignature(p *Signed[*LaneProposal]) error { - if err := p.Msg().VerifyPayload(); err != nil { - return fmt.Errorf("VerifyPayload(): %w", err) - } - if err := p.VerifySignature(); err != nil { - return fmt.Errorf("VerifySignature(): %w", err) - } - return nil +// Verify checks the proposal's internal integrity (payload hash). Committee +// membership is separate: a lane is not tied to a single committee/epoch. +func (m *LaneProposal) Verify() error { + return m.block.Verify() } // LaneProposalConv is a protobuf converter for LaneProposal. diff --git a/sei-tendermint/autobahn/types/msg.go b/sei-tendermint/autobahn/types/msg.go index 6f0fab5f62..490745a832 100644 --- a/sei-tendermint/autobahn/types/msg.go +++ b/sei-tendermint/autobahn/types/msg.go @@ -188,19 +188,11 @@ func (m *Signed[T]) Sig() *Signature { return m.sig } // Key returns the key whish signed the message. func (m *Signed[T]) Key() PublicKey { return m.sig.key } -// VerifySignature verifies the cryptographic signature. -func (m *Signed[T]) VerifySignature() error { +// VerifySig verifies the cryptographic signature. +func (m *Signed[T]) VerifySig() error { return m.sig.key.key.VerifyWithTag(autobahnTag, m.hashed.hash[:], m.sig.sig) } -// VerifySig verifies the signer is a committee replica and the signature. -func (m *Signed[T]) VerifySig(c *Committee) error { - if !c.HasReplica(m.sig.key) { - return fmt.Errorf("%q is not a replica", m.sig.key) - } - return m.VerifySignature() -} - // verifyQC verifies a slice of signatures and checks if they form a quorum. func (m *Hashed[T]) verifyQC(c *Committee, quorumWeight uint64, sigs []*Signature) error { done := map[PublicKey]struct{}{} @@ -212,7 +204,10 @@ func (m *Hashed[T]) verifyQC(c *Committee, quorumWeight uint64, sigs []*Signatur done[sig.key] = struct{}{} weight += c.Weight(sig.key) sm := &Signed[T]{hashed: m, sig: sig} - if err := sm.VerifySig(c); err != nil { + if !c.HasReplica(sm.Key()) { + return fmt.Errorf("%q is not a replica", sm.Key()) + } + if err := sm.VerifySig(); err != nil { return err } } diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 72996397c3..aef80744d0 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -420,7 +420,10 @@ func (m *FullProposal) Verify(vs ViewSpec) error { return fmt.Errorf("proposer %q, want %q", got, want) } // Verify the proposer's signature. - if err := m.proposal.VerifySig(c); err != nil { + if !c.HasReplica(m.proposal.Key()) { + return fmt.Errorf("%q is not a replica", m.proposal.Key()) + } + if err := m.proposal.VerifySig(); err != nil { return fmt.Errorf("proposal signature: %w", err) } // Do we have the required timeoutQC? diff --git a/sei-tendermint/autobahn/types/testonly.go b/sei-tendermint/autobahn/types/testonly.go index 82129f61d7..d9c5291213 100644 --- a/sei-tendermint/autobahn/types/testonly.go +++ b/sei-tendermint/autobahn/types/testonly.go @@ -146,7 +146,7 @@ func SignedForTesting[T Msg](msg T, sig *Signature) *Signed[T] { // NewBlockForTesting builds a Block with an injected payload hash instead of computing // payload.Hash(). FOR TESTS/BENCHMARKS ONLY: the header's payloadHash need not match the -// payload, so LaneProposal.VerifyPayload will fail. This skips a full marshal + SHA-256 of the payload. +// payload, so LaneProposal.Verify will fail. This skips a full marshal + SHA-256 of the payload. func NewBlockForTesting( lane LaneID, blockNumber BlockNumber, diff --git a/sei-tendermint/autobahn/types/timeout.go b/sei-tendermint/autobahn/types/timeout.go index f043796ca4..27c261fd96 100644 --- a/sei-tendermint/autobahn/types/timeout.go +++ b/sei-tendermint/autobahn/types/timeout.go @@ -79,7 +79,10 @@ func (m *FullTimeoutVote) Verify(ep *Epoch) error { return err } c := ep.Committee() - if err := m.vote.VerifySig(c); err != nil { + if !c.HasReplica(m.vote.Key()) { + return fmt.Errorf("%q is not a replica", m.vote.Key()) + } + if err := m.vote.VerifySig(); err != nil { return err } if want, ok := m.vote.Msg().latestPrepareQCView().Get(); ok { @@ -166,7 +169,10 @@ func (m *TimeoutQC) Verify(ep *Epoch, prev utils.Option[*CommitQC]) error { } weight += c.Weight(v.sig.key) done[v.sig.key] = struct{}{} - if err := v.VerifySig(c); err != nil { + if !c.HasReplica(v.Key()) { + return fmt.Errorf("%q is not a replica", v.Key()) + } + if err := v.VerifySig(); err != nil { return err } } diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index cff71ec3a0..648de37adf 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -286,7 +286,11 @@ func (s *State) PushAppVote(ctx context.Context, v *types.Signed[*types.AppVote] if err := v.Msg().Proposal().Verify(commitQC); err != nil { return fmt.Errorf("invalid vote: %w", err) } - if err := v.VerifySig(epoch.Committee()); err != nil { + c := epoch.Committee() + if !c.HasReplica(v.Key()) { + return fmt.Errorf("%q is not a replica", v.Key()) + } + if err := v.VerifySig(); err != nil { return fmt.Errorf("v.VerifySig(): %w", err) } for inner, ctrl := range s.inner.Lock() { @@ -346,13 +350,14 @@ func (s *State) PushBlock(ctx context.Context, p *types.Signed[*types.LanePropos } lane := h.Lane() n := h.BlockNumber() - if err := types.VerifyLaneProposalPayloadAndSignature(p); err != nil { - return err + if err := p.Msg().Verify(); err != nil { + return fmt.Errorf("Verify(): %w", err) + } + if err := p.VerifySig(); err != nil { + return fmt.Errorf("VerifySig(): %w", err) } for inner, ctrl := range s.inner.Lock() { - if !laneAcceptedUnder(inner, func(ep *types.Epoch) bool { - return p.Msg().VerifyCommitteeMembership(ep.Committee()) == nil - }) { + if !epochForLane(inner, lane).IsPresent() { return nil } if err := ctrl.WaitUntil(ctx, func() bool { @@ -407,8 +412,8 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote h := vote.Msg().Header() lane := h.Lane() n := h.BlockNumber() - if err := vote.VerifySignature(); err != nil { - return fmt.Errorf("VerifySignature(): %w", err) + if err := vote.VerifySig(); err != nil { + return fmt.Errorf("VerifySig(): %w", err) } for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { @@ -428,9 +433,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return nil } // TODO: accept future-epoch joiner votes. - if !laneAcceptedUnder(inner, func(ep *types.Epoch) bool { - return laneVoteCommitteeOK(ep, vote) - }) { + if !epochForVote(inner, vote).IsPresent() { return nil } applied := inner.epoch.Load() @@ -444,24 +447,37 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return nil } -// laneVoteCommitteeOK reports whether the vote's header lane and signer are in ep's committee. -func laneVoteCommitteeOK(ep *types.Epoch, vote *types.Signed[*types.LaneVote]) bool { - c := ep.Committee() - return vote.Msg().Verify(c) == nil && c.HasReplica(vote.Key()) +// epochForVote returns the applied or Anchor epoch under which vote's lane and +// signer verify. Prefers applied; falls back to Anchor when that is a different +// EpochIndex. +func epochForVote(inner *inner, vote *types.Signed[*types.LaneVote]) utils.Option[*types.Epoch] { + match := func(ep *types.Epoch) bool { + c := ep.Committee() + return vote.Msg().Verify(c) == nil && c.HasReplica(vote.Key()) + } + applied := inner.epoch.Load() + if match(applied) { + return utils.Some(applied) + } + ae, ok := inner.anchorEpoch.Get() + if !ok || ae.EpochIndex() == applied.EpochIndex() || !match(ae) { + return utils.None[*types.Epoch]() + } + return utils.Some(ae) } -// laneAcceptedUnder reports whether accept holds for the applied epoch, or for -// the Anchor epoch when present and a different EpochIndex. -func laneAcceptedUnder(inner *inner, accept func(*types.Epoch) bool) bool { +// epochForLane returns the applied epoch if it has lane, otherwise the Anchor +// epoch when that is a different EpochIndex and has lane. +func epochForLane(inner *inner, lane types.LaneID) utils.Option[*types.Epoch] { applied := inner.epoch.Load() - if accept(applied) { - return true + if applied.Committee().HasLane(lane) { + return utils.Some(applied) } ae, ok := inner.anchorEpoch.Get() - if !ok || ae.EpochIndex() == applied.EpochIndex() { - return false + if !ok || ae.EpochIndex() == applied.EpochIndex() || !ae.Committee().HasLane(lane) { + return utils.None[*types.Epoch]() } - return accept(ae) + return utils.Some(ae) } // headers collects headers for the given range under ep (the CommitQC's road epoch). diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 086dbadf21..7606d26505 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -690,10 +690,10 @@ func TestHeaders_WaitsForPrevEpochLaneVote(t *testing.T) { return fmt.Errorf("anchor epoch = %d, want < %d", ae.EpochIndex(), epLeave.EpochIndex()) } } - if laneVoteCommitteeOK(epLeave, leaverVote) { + if leaverVote.Msg().Verify(epLeave.Committee()) == nil && epLeave.Committee().HasReplica(leaverVote.Key()) { return fmt.Errorf("leaver must fail under applied") } - if !laneVoteCommitteeOK(ep0, leaverVote) { + if leaverVote.Msg().Verify(ep0.Committee()) != nil || !ep0.Committee().HasReplica(leaverVote.Key()) { return fmt.Errorf("leaver must pass under anchor") } diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 336e3c8e07..37c6413100 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -110,12 +110,12 @@ func (p *persistedInner) validate(ep *types.Epoch) error { return fmt.Errorf("corrupt persisted state: CommitVote present without PrepareQC") } if v, ok := p.CommitVote.Get(); ok { - if err := checkViewAndSig("CommitVote", v.Msg().Proposal().View(), v.VerifySig(committee)); err != nil { + if err := checkViewAndSig("CommitVote", v.Msg().Proposal().View(), verifyReplicaSig(committee, v)); err != nil { return err } } if v, ok := p.PrepareVote.Get(); ok { - if err := checkViewAndSig("PrepareVote", v.Msg().Proposal().View(), v.VerifySig(committee)); err != nil { + if err := checkViewAndSig("PrepareVote", v.Msg().Proposal().View(), verifyReplicaSig(committee, v)); err != nil { return err } } @@ -127,6 +127,13 @@ func (p *persistedInner) validate(ep *types.Epoch) error { return nil } +func verifyReplicaSig[T types.Msg](c *types.Committee, v *types.Signed[T]) error { + if !c.HasReplica(v.Key()) { + return fmt.Errorf("%q is not a replica", v.Key()) + } + return v.VerifySig() +} + // innerProtoConv is a protobuf converter for persistedInner. var innerProtoConv = protoutils.Conv[*persistedInner, *pb.PersistedInner]{ Encode: func(m *persistedInner) *pb.PersistedInner { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index db89dda261..0b7b3e5e1e 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -185,7 +185,10 @@ 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() - if err := vote.VerifySig(committee); err != nil { + if !committee.HasReplica(vote.Key()) { + return fmt.Errorf("%q is not a replica", vote.Key()) + } + if err := vote.VerifySig(); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } for pv := range s.prepareVotes.Lock() { @@ -197,7 +200,10 @@ 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() - if err := vote.VerifySig(committee); err != nil { + if !committee.HasReplica(vote.Key()) { + return fmt.Errorf("%q is not a replica", vote.Key()) + } + if err := vote.VerifySig(); err != nil { return fmt.Errorf("vote.VerifySig(): %w", err) } for cv := range s.commitVotes.Lock() { diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 14157900d1..bc5d86b490 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -158,7 +158,8 @@ func (i *inner) insertAppProposal(appProposal *types.AppProposal) error { // insertBlock inserts a pre-verified 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). +// payload integrity before calling; insertBlock matches the header against +// the stored FullCommitQC (unlike insertQC, which verifies the QC itself). // // insertBlock does NOT advance nextBlock — callers should call // updateNextBlock after inserting one or more blocks. This separation @@ -289,14 +290,9 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { } } for _, b := range suffix.Blocks { - entry := inner.qcs[b.Number] - c := entry.epoch.Committee() prop := types.NewLaneProposal(b.Block) - if err := prop.VerifyPayload(); err != nil { - return nil, fmt.Errorf("verify block %d payload from BlockDB: %w", b.Number, err) - } - if err := prop.VerifyCommitteeMembership(c); err != nil { - return nil, fmt.Errorf("verify block %d membership from BlockDB: %w", b.Number, err) + if err := prop.Verify(); err != nil { + return nil, fmt.Errorf("verify block %d from BlockDB: %w", b.Number, err) } if err := inner.insertBlock(b.Number, b.Block); err != nil { return nil, fmt.Errorf("insert block %d from BlockDB: %w", b.Number, err) @@ -376,15 +372,10 @@ func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*ty } } byHash := map[types.BlockHeaderHash]*types.Block{} - committee := ep.Committee() for _, b := range blocks { byHash[b.Header().Hash()] = b - prop := types.NewLaneProposal(b) - if err := prop.VerifyPayload(); err != nil { - return fmt.Errorf("VerifyPayload(): %w", err) - } - if err := prop.VerifyCommitteeMembership(committee); err != nil { - return fmt.Errorf("VerifyCommitteeMembership(): %w", err) + if err := types.NewLaneProposal(b).Verify(); err != nil { + return fmt.Errorf("block.Verify(): %w", err) } } // Atomically insert QC and blocks. @@ -429,7 +420,6 @@ func (s *State) QC(ctx context.Context, n types.GlobalBlockNumber) (*types.FullC // the height is already in the contiguous block prefix (n < nextBlock) — in // that case the block is dropped silently (already stored or executed/evicted). func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block *types.Block) error { - var ep *types.Epoch for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { return n < inner.nextQC }); err != nil { return err @@ -439,16 +429,11 @@ func (s *State) PushBlock(ctx context.Context, n types.GlobalBlockNumber, block if n < inner.nextBlock { return nil } - // n in [nextBlock, nextQC): QC (and its verify-epoch) is contiguous. - ep = inner.qcs[n].epoch - } - // Verify outside the lock against the epoch stashed with the QC. - prop := types.NewLaneProposal(block) - if err := prop.VerifyPayload(); err != nil { - return fmt.Errorf("VerifyPayload(): %w", err) } - if err := prop.VerifyCommitteeMembership(ep.Committee()); err != nil { - return fmt.Errorf("VerifyCommitteeMembership(): %w", err) + // Payload integrity outside the lock; insertBlock matches the header against + // the stored FullCommitQC (membership is implied by that QC). + if err := types.NewLaneProposal(block).Verify(); err != nil { + return fmt.Errorf("block.Verify(): %w", err) } for inner, ctrl := range s.inner.Lock() { // insertBlock may no-op if n fell into the contiguous prefix (or was From 3d78676b6043be7eebb4fa057ec7b8dc780c8bdb Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 18 Aug 2026 21:25:51 -0700 Subject: [PATCH 07/19] refactor(autobahn): replace consensus WAL CommitQC with CommitQCIndex Persist only the tip road index for ErrAvailBehindConsensus; runtime tip and epoch come from ConsensusSpec on inner.spec, matching the multi-epoch review. Co-authored-by: Cursor --- .../internal/autobahn/autobahn.proto | 8 +- .../internal/autobahn/consensus/inner.go | 58 +++-- .../internal/autobahn/consensus/inner_test.go | 202 +++++++----------- .../autobahn/consensus/persisted_inner.go | 65 ++++-- .../consensus/persisted_inner_test.go | 14 +- .../internal/autobahn/consensus/state.go | 8 +- .../internal/autobahn/pb/autobahn.pb.go | 114 +++++----- .../autobahn/pb/autobahn.wireguard.go | 2 +- 8 files changed, 237 insertions(+), 234 deletions(-) diff --git a/sei-tendermint/internal/autobahn/autobahn.proto b/sei-tendermint/internal/autobahn/autobahn.proto index 9aacb4dd49..f983ad5a08 100644 --- a/sei-tendermint/internal/autobahn/autobahn.proto +++ b/sei-tendermint/internal/autobahn/autobahn.proto @@ -203,9 +203,11 @@ message FullTimeoutVote { // Do NOT persist internal derived fields (e.g., cached computations, runtime state). // Derived fields are implementation-dependent and should be recomputed on load. message PersistedInner { - reserved "commit_vote", "prepare_vote"; - reserved 4, 5; - optional CommitQC commit_qc = 1; + reserved "commit_vote", "prepare_vote", "commit_qc"; + reserved 4, 5, 1; + // Tip CommitQC road index for ErrAvailBehindConsensus on restore. + // Absent means no tip yet (genesis view 0). Runtime tip comes from ConsensusSpec. + optional uint64 commit_qc_index = 9; optional PrepareQC prepare_qc = 2; optional TimeoutQC timeout_qc = 3; diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 99eb78539f..c6bdc6ec66 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -3,11 +3,13 @@ // # What We Persist // // All consensus state is persisted atomically in a single A/B file pair (inner_a.pb/inner_b.pb): -// - CommitQC: justified entering the current index +// - CommitQCIndex: tip road index for ErrAvailBehindConsensus on restore // - TimeoutQC: justified entering the current view number // - PrepareQC: needed for timeoutVote on restart // - PrepareVote, CommitVote, TimeoutVote: this node's votes for the current view // +// Runtime CommitQC + next-view epoch come from avail's ConsensusSpec, not the WAL. +// // # Why We Persist // // Safety: Votes prevent double-voting on restart - a critical safety property. @@ -55,8 +57,13 @@ // - Inconsistent state: Returns error to caller with message indicating which field is corrupt // Examples of inconsistent state: // - Vote from a future view (how could we vote for a view we haven't reached?) -// - TimeoutQC at index > 0 without CommitQC (how did we advance past index 0?) -// - TimeoutQC at index > CommitQC.Index + 1 (how did we skip intermediate commits?) +// - TimeoutQC at index > 0 without CommitQCIndex (how did we advance past index 0?) +// - TimeoutQC at index > CommitQCIndex + 1 (how did we skip intermediate commits?) +// - WAL tip ahead of ConsensusSpec: ErrAvailBehindConsensus +// - Spec ahead of WAL tip: install spec, discard per-view WAL state +// - Equal tip: keep WAL votes/QCs if persistedInner.validate(spec) passes +// (e.g. reject future-view votes, TimeoutQC index ≠ NextIndexOpt(spec.CommitQC), +// bad signatures, CommitVote without PrepareQC) // // # Write Behavior // @@ -71,9 +78,10 @@ // - Votes (prepareVote, commitVote, timeoutVote): YES - rebroadcast via sendUpdates // - TimeoutQC: YES - rebroadcast via myTimeoutQC watch // - CommitQC: NO - used locally for view justification but not rebroadcast; -// CommitQCs are served via StreamCommitQCs from the data layer, not from -// the persisted viewSpec. TODO: consider rebroadcasting CommitQC on restart -// to help peers sync faster after cluster-wide outages. +// the runtime tip comes from ConsensusSpec, while the WAL stores only +// CommitQCIndex. CommitQCs are served via StreamCommitQCs from the data +// layer. TODO: consider rebroadcasting CommitQC on restart to help peers +// sync faster after cluster-wide outages. package consensus import ( @@ -93,12 +101,12 @@ const innerFile = "inner" type inner struct { persistedInner - epoch *types.Epoch + spec types.ConsensusSpec } // View returns the current view, embedding the epoch's index. func (i inner) View() types.View { - vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epoch: i.epoch} + vs := types.ViewSpec{CommitQC: i.spec.CommitQC, TimeoutQC: i.TimeoutQC, Epoch: i.spec.Epoch} return vs.View() } @@ -111,15 +119,16 @@ func newInner( spec types.ConsensusSpec, ) (inner, error) { var persisted persistedInner + persistedViewIdx := types.RoadIndex(0) if p, ok := loaded.Get(); ok { decoded, err := innerProtoConv.Decode(p) if err != nil { return inner{}, fmt.Errorf("corrupt persisted state: %w", err) } persisted = *decoded + persistedViewIdx = nextIndexOpt(persisted.CommitQCIndex) } - persistedViewIdx := types.NextIndexOpt(persisted.CommitQC) specViewIdx := types.NextIndexOpt(spec.CommitQC) if persistedViewIdx > specViewIdx { return inner{}, fmt.Errorf("%w: persisted tip %d > ConsensusSpec tip %d", @@ -127,25 +136,24 @@ func newInner( } if specViewIdx == persistedViewIdx { - // Same tip: take CommitQC from the spec; keep WAL votes / view QCs. - out := persisted - out.CommitQC = spec.CommitQC - if err := out.validate(spec.Epoch); err != nil { + // Same tip: keep WAL votes / view QCs; CommitQC+epoch from the spec. + if err := persisted.validate(spec); err != nil { return inner{}, err } - logger.Info("restored consensus state", "state", innerProtoConv.Encode(&out)) - return inner{persistedInner: out, epoch: spec.Epoch}, nil + persisted.CommitQCIndex = commitQCIndex(spec) + logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted)) + return inner{persistedInner: persisted, spec: spec}, nil } - out := persistedInner{CommitQC: spec.CommitQC} + out := persistedInner{CommitQCIndex: commitQCIndex(spec)} logger.Info("restored consensus state from avail ConsensusSpec", "state", innerProtoConv.Encode(&out)) - return inner{persistedInner: out, epoch: spec.Epoch}, nil + return inner{persistedInner: out, spec: spec}, nil } -// pushSpecFromAvail installs avail's ConsensusSpec tip and clears per-view state. +// pushSpec installs avail's ConsensusSpec tip and clears per-view state. // Specs that do not advance the view are ignored, which covers the tipless spec // published before the first CommitQC. -func (s *State) pushSpecFromAvail(spec types.ConsensusSpec) error { +func (s *State) pushSpec(spec types.ConsensusSpec) error { specViewIdx := types.NextIndexOpt(spec.CommitQC) if specViewIdx <= s.innerRecv.Load().View().Index { return nil @@ -156,7 +164,10 @@ func (s *State) pushSpecFromAvail(spec types.ConsensusSpec) error { return nil } // CommitQC advances to new index; clear all state for new view. - iSend.Store(inner{persistedInner: persistedInner{CommitQC: spec.CommitQC}, epoch: spec.Epoch}) + iSend.Store(inner{ + persistedInner: persistedInner{CommitQCIndex: commitQCIndex(spec)}, + spec: spec, + }) } return nil } @@ -174,7 +185,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.spec.Epoch, i.spec.CommitQC); err != nil { return fmt.Errorf("qc.Verify(): %w", err) } for isend := range s.inner.Lock() { @@ -183,7 +194,10 @@ func (s *State) pushTimeoutQC(ctx context.Context, qc *types.TimeoutQC) error { return nil } // TimeoutQC advances view number; clear votes and prepareQC (stale view). - isend.Store(inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(qc)}, epoch: i.epoch}) + isend.Store(inner{ + persistedInner: persistedInner{CommitQCIndex: i.CommitQCIndex, TimeoutQC: utils.Some(qc)}, + spec: i.spec, + }) } return nil } diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 166033a03a..43a87a9e1e 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -43,17 +43,28 @@ func newTestAvail(t *testing.T, registry *epoch.Registry, key types.SecretKey) ( return ds, av } -// seedPersistedInner is a test helper that persists a persistedInner using the public API. -func seedPersistedInner(dir string, state *persistedInner) { +// seedPersistedInner persists view-local WAL state. tip is recorded as CommitQCIndex +// for ErrAvailBehindConsensus comparison on restore; runtime CommitQC comes from avail. +func seedPersistedInner(dir string, tip utils.Option[*types.CommitQC], state *persistedInner) { p, _, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) if err != nil { panic(err) } - if err := p.Persist(innerProtoConv.Encode(state)); err != nil { + if err := p.Persist(encodeWal(tip, state)); err != nil { panic(err) } } +func encodeWal(tip utils.Option[*types.CommitQC], state *persistedInner) *pb.PersistedInner { + s := *state + if cqc, ok := tip.Get(); ok { + s.CommitQCIndex = utils.Some(cqc.Index()) + } else { + s.CommitQCIndex = utils.None[types.RoadIndex]() + } + return innerProtoConv.Encode(&s) +} + // loadInner is a test helper that loads persisted data and creates inner. // Mirrors what NewState does: avail first (aligned to the WAL tip via PushCommitQC), // then newInner. @@ -69,12 +80,8 @@ func loadInner(t *testing.T, dir string, registry *epoch.Registry, keys []types. go func() { _ = utils.IgnoreCancel(av.Run(ctx)) }() if p, ok := persisted.Get(); ok { - decoded, err := innerProtoConv.Decode(p) - if err != nil { - return inner{}, err - } - if cqc, ok := decoded.CommitQC.Get(); ok { - if err := alignAvailToTip(ctx, t, av, registry, keys, cqc); err != nil { + if tipIdx := walTipIndex(p); tipIdx > 0 { + if err := alignAvailToTip(ctx, t, av, registry, keys, tipIdx-1); err != nil { return inner{}, err } } @@ -82,10 +89,10 @@ func loadInner(t *testing.T, dir string, registry *epoch.Registry, keys []types. return newInner(persisted, av.SubscribeConsensusSpec().Load()) } -// alignAvailToTip pushes CommitQCs 0..tip.Index() through avail and waits until -// the tip index is durable. The QCs are freshly built for the registry — the tip -// CommitQC used at restore comes from ConsensusSpec, not the WAL bytes. -// Callers must keep tip.Index() small — EpochLength boundaries are not replayed +// alignAvailToTip pushes CommitQCs 0..tipIndex through avail and waits until +// that tip is durable. The QCs are freshly built for the registry — the tip +// CommitQC used at restore comes from ConsensusSpec, not the WAL. +// Callers must keep tipIndex small — EpochLength boundaries are not replayed // in unit tests. func alignAvailToTip( ctx context.Context, @@ -93,13 +100,13 @@ func alignAvailToTip( av *avail.State, registry *epoch.Registry, keys []types.SecretKey, - tip *types.CommitQC, + tipIndex types.RoadIndex, ) error { t.Helper() - require.LessOrEqual(t, tip.Index(), types.RoadIndex(64), "alignAvailToTip: tip too high for unit replay") + require.LessOrEqual(t, tipIndex, types.RoadIndex(64), "alignAvailToTip: tip too high for unit replay") var prev utils.Option[*types.CommitQC] - for idx := types.RoadIndex(0); idx <= tip.Index(); idx++ { + for idx := types.RoadIndex(0); idx <= tipIndex; idx++ { ep, err := registry.EpochAt(idx) if err != nil { return err @@ -112,7 +119,7 @@ func alignAvailToTip( } _, err := av.LastCommitQC().Wait(ctx, func(o utils.Option[*types.CommitQC]) bool { c, ok := o.Get() - return ok && c.Index() >= tip.Index() + return ok && c.Index() >= tipIndex }) return err } @@ -135,7 +142,7 @@ func TestNewInnerEmpty(t *testing.T) { require.False(t, i.PrepareVote.IsPresent(), "prepareVote should be None") require.False(t, i.CommitVote.IsPresent(), "commitVote should be None") require.False(t, i.TimeoutVote.IsPresent(), "timeoutVote should be None") - require.Equal(t, types.EpochIndex(0), i.epoch.EpochIndex()) + require.Equal(t, types.EpochIndex(0), i.spec.Epoch.EpochIndex()) } // TestNewInner_RejectsWALAheadOfSpec: after avail catch-up, ConsensusSpec must @@ -161,13 +168,11 @@ func TestNewInner_RejectsWALAheadOfSpec(t *testing.T) { view := types.View{Index: last + 1, Number: 0, EpochIndex: 1} proposal := types.GenProposalForEpoch(rng, ep1, view) vote := types.Sign(keys[0], types.NewPrepareVote(proposal)) - persisted := persistedInner{ - CommitQC: utils.Some(qcLast), - PrepareVote: utils.Some(vote), - } + persistedTip := utils.Some(qcLast) + persisted := persistedInner{PrepareVote: utils.Some(vote)} genesis := types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep0} - _, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), genesis) + _, err := newInner(utils.Some(encodeWal(persistedTip, &persisted)), genesis) require.ErrorIs(t, err, ErrAvailBehindConsensus) } @@ -192,16 +197,14 @@ func TestNewInner_EqualTipKeepsVotes(t *testing.T) { view := types.View{Index: last + 1, Number: 0, EpochIndex: 1} proposal := types.GenProposalForEpoch(rng, ep1, view) vote := types.Sign(keys[0], types.NewPrepareVote(proposal)) - persisted := persistedInner{ - CommitQC: utils.Some(qcLast), - PrepareVote: utils.Some(vote), - } + persistedTip := utils.Some(qcLast) + persisted := persistedInner{PrepareVote: utils.Some(vote)} spec := types.ConsensusSpec{CommitQC: utils.Some(qcLast), Epoch: ep1} - i, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), spec) + i, err := newInner(utils.Some(encodeWal(persistedTip, &persisted)), spec) require.NoError(t, err) require.Equal(t, last+1, i.View().Index) - require.Equal(t, types.EpochIndex(1), i.epoch.EpochIndex()) + require.Equal(t, types.EpochIndex(1), i.spec.Epoch.EpochIndex()) got, ok := i.PrepareVote.Get() require.True(t, ok) require.Equal(t, view, got.Msg().Proposal().View()) @@ -258,15 +261,13 @@ func TestRestore_BoundaryCatchUpSpecCoversWAL(t *testing.T) { view := types.View{Index: last + 1, Number: 0, EpochIndex: 1} proposal := types.GenProposalForEpoch(rng, spec.Epoch, view) vote := types.Sign(keys[0], types.NewPrepareVote(proposal)) - persisted := persistedInner{ - CommitQC: spec.CommitQC, - PrepareVote: utils.Some(vote), - } + persistedTip := spec.CommitQC + persisted := persistedInner{PrepareVote: utils.Some(vote)} - i, err := newInner(utils.Some(innerProtoConv.Encode(&persisted)), spec) + i, err := newInner(utils.Some(encodeWal(persistedTip, &persisted)), spec) require.NoError(t, err) require.Equal(t, last+1, i.View().Index) - require.Equal(t, types.EpochIndex(1), i.epoch.EpochIndex()) + require.Equal(t, types.EpochIndex(1), i.spec.Epoch.EpochIndex()) got, ok := i.PrepareVote.Get() require.True(t, ok, "equal-tip restore must keep anti-equivocation vote") require.Equal(t, view, got.Msg().Proposal().View()) @@ -282,7 +283,7 @@ func TestNewInnerPrepareVote(t *testing.T) { genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) vote := types.Sign(key, types.NewPrepareVote(genesisProposal)) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ PrepareVote: utils.Some(vote), }) @@ -305,7 +306,7 @@ func TestNewInnerCommitVote(t *testing.T) { prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) vote := types.Sign(key, types.NewCommitVote(genesisProposal)) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ PrepareQC: utils.Some(prepareQC), CommitVote: utils.Some(vote), }) @@ -327,7 +328,7 @@ func TestNewInnerTimeoutVote(t *testing.T) { key := keys[0] vote := types.NewFullTimeoutVote(key, types.View{Index: 0, Number: 0}, utils.None[*types.PrepareQC]()) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ TimeoutVote: utils.Some(vote), }) @@ -352,7 +353,7 @@ func TestNewInnerAllVotes(t *testing.T) { commitVote := types.Sign(key, types.NewCommitVote(genesisProposal)) timeoutVote := types.NewFullTimeoutVote(key, types.View{Index: 0, Number: 0}, utils.None[*types.PrepareQC]()) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ PrepareQC: utils.Some(prepareQC), PrepareVote: utils.Some(prepareVote), CommitVote: utils.Some(commitVote), @@ -377,7 +378,7 @@ func TestNewInnerPartialState(t *testing.T) { genesisProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ PrepareVote: utils.Some(prepareVote), }) @@ -403,15 +404,13 @@ func TestNewInnerCommitQC(t *testing.T) { } qc := types.NewCommitQC(votes) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(qc), - }) + seedPersistedInner(dir, utils.Some(qc), &persistedInner{}) // Load and verify i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) - require.True(t, i.CommitQC.IsPresent(), "CommitQC should be loaded") - loadedQC, ok := i.CommitQC.Get() + require.True(t, i.spec.CommitQC.IsPresent(), "CommitQC should be loaded") + loadedQC, ok := i.spec.CommitQC.Get() require.True(t, ok) require.Equal(t, types.RoadIndex(5), loadedQC.Proposal().Index()) // View should be (6, 0) since CommitQC at index 5 advances to index 6 @@ -439,10 +438,7 @@ func TestNewInnerTimeoutQC(t *testing.T) { } timeoutQC := types.NewTimeoutQC(timeoutVotes) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - TimeoutQC: utils.Some(timeoutQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{TimeoutQC: utils.Some(timeoutQC)}) // Load and verify i, err := loadInner(t, dir, registry, keys) @@ -464,7 +460,7 @@ func TestNewInnerTimeoutQCOnlyGenesis(t *testing.T) { } timeoutQC := types.NewTimeoutQC(timeoutVotes) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ TimeoutQC: utils.Some(timeoutQC), }) @@ -487,7 +483,7 @@ func TestNewInnerTimeoutQCWithoutCommitQCError(t *testing.T) { } timeoutQC := types.NewTimeoutQC(timeoutVotes) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ TimeoutQC: utils.Some(timeoutQC), }) @@ -518,10 +514,7 @@ func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { } timeoutQC := types.NewTimeoutQC(timeoutVotes) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - TimeoutQC: utils.Some(timeoutQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{TimeoutQC: utils.Some(timeoutQC)}) // Should return error - TimeoutQC index must equal CommitQC.Index + 1 _, err := loadInner(t, dir, registry, keys) @@ -551,10 +544,7 @@ func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { } timeoutQC := types.NewTimeoutQC(timeoutVotes) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - TimeoutQC: utils.Some(timeoutQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{TimeoutQC: utils.Some(timeoutQC)}) // Load - stale TimeoutQC should be treated as corrupt state _, err := loadInner(t, dir, registry, keys) @@ -583,15 +573,12 @@ func TestNewInnerViewSpecValidBothQCs(t *testing.T) { } timeoutQC := types.NewTimeoutQC(timeoutVotes) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - TimeoutQC: utils.Some(timeoutQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{TimeoutQC: utils.Some(timeoutQC)}) // Load - both should be present i, err := loadInner(t, dir, registry, keys) require.NoError(t, err) - require.True(t, i.CommitQC.IsPresent(), "CommitQC should be loaded") + require.True(t, i.spec.CommitQC.IsPresent(), "CommitQC should be loaded") require.True(t, i.TimeoutQC.IsPresent(), "TimeoutQC should be loaded") // View should be (6, 3) - TimeoutQC at (6, 2) advances to (6, 3) require.Equal(t, types.View{Index: 6, Number: 3}, i.View()) @@ -616,10 +603,7 @@ func TestNewInnerStaleVoteError(t *testing.T) { staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) staleVote := types.Sign(keys[0], types.NewPrepareVote(staleProposal)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareVote: utils.Some(staleVote), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(staleVote)}) _, err := loadInner(t, dir, registry, keys) require.Error(t, err) @@ -644,10 +628,7 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewPrepareVote(futureProposal)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareVote: utils.Some(futureVote), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(futureVote)}) // Should return error - future votes indicate corrupt state _, err := loadInner(t, dir, registry, keys) @@ -673,10 +654,7 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewCommitVote(futureProposal)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - CommitVote: utils.Some(futureVote), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{CommitVote: utils.Some(futureVote)}) // Should return error _, err := loadInner(t, dir, registry, keys) @@ -701,10 +679,7 @@ func TestNewInnerFutureTimeoutVoteError(t *testing.T) { // Create future timeout vote at view (10, 0) futureVote := types.NewFullTimeoutVote(keys[0], types.View{Index: 10, Number: 0}, utils.None[*types.PrepareQC]()) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - TimeoutVote: utils.Some(futureVote), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{TimeoutVote: utils.Some(futureVote)}) // Should return error _, err := loadInner(t, dir, registry, keys) @@ -730,10 +705,7 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) currentVote := types.Sign(keys[0], types.NewPrepareVote(currentProposal)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareVote: utils.Some(currentVote), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(currentVote)}) // Should succeed - current view votes are valid i, err := loadInner(t, dir, registry, keys) @@ -766,10 +738,7 @@ func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { } timeoutQC := types.NewTimeoutQC(timeoutVotes) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - TimeoutQC: utils.Some(timeoutQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{TimeoutQC: utils.Some(timeoutQC)}) // Should return error - invalid signatures on TimeoutQC _, err := loadInner(t, dir, registry, keys) @@ -796,10 +765,7 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(currentProposal)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareVote: utils.Some(badVote), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(badVote)}) // Should return error - current view votes must have valid signatures _, err := loadInner(t, dir, registry, keys) @@ -827,10 +793,7 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(staleProposal)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareVote: utils.Some(badVote), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(badVote)}) _, err := loadInner(t, dir, registry, keys) require.Error(t, err) @@ -846,7 +809,7 @@ func TestNewInnerPrepareQC(t *testing.T) { proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC(keys, proposal) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ PrepareQC: utils.Some(prepareQC), }) @@ -875,10 +838,7 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { staleProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 3, Number: 0}) stalePrepareQC := makePrepareQC(keys, staleProposal) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareQC: utils.Some(stalePrepareQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(stalePrepareQC)}) _, err := loadInner(t, dir, registry, keys) require.Error(t, err) @@ -895,7 +855,7 @@ func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { proposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 0, Number: 0}) commitVote := types.Sign(keys[0], types.NewCommitVote(proposal)) - seedPersistedInner(dir, &persistedInner{ + seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ CommitVote: utils.Some(commitVote), }) @@ -922,10 +882,7 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { futureProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 10, Number: 0}) prepareQC := makePrepareQC(keys, futureProposal) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareQC: utils.Some(prepareQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(prepareQC)}) // Should return error - future prepareQC indicates corrupt state _, err := loadInner(t, dir, registry, keys) @@ -951,10 +908,7 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareQC: utils.Some(prepareQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(prepareQC)}) // Should succeed - current view prepareQC is valid i, err := loadInner(t, dir, registry, keys) @@ -984,10 +938,7 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(otherKeys, currentProposal) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareQC: utils.Some(prepareQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(prepareQC)}) // Should return error - current view prepareQC has invalid signatures _, err := loadInner(t, dir, registry, keys) @@ -1014,10 +965,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), - PrepareQC: utils.Some(prepareQC), - }) + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(prepareQC)}) // Load state i, err := loadInner(t, dir, registry, keys) @@ -1063,8 +1011,7 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { commitVote := types.Sign(keys[0], types.NewCommitVote(currentProposal)) timeoutVote := types.NewFullTimeoutVote(keys[0], types.View{Index: 6, Number: 0}, utils.Some(prepareQC)) - seedPersistedInner(dir, &persistedInner{ - CommitQC: utils.Some(commitQC), + seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{ PrepareQC: utils.Some(prepareQC), PrepareVote: utils.Some(prepareVote), CommitVote: utils.Some(commitVote), @@ -1088,7 +1035,10 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { timeoutQC := types.NewTimeoutQC(timeoutVotes) // Simulate pushTimeoutQC's Update callback - newInner := inner{persistedInner: persistedInner{CommitQC: i.CommitQC, TimeoutQC: utils.Some(timeoutQC)}, epoch: i.epoch} + newInner := inner{ + persistedInner: persistedInner{CommitQCIndex: i.CommitQCIndex, TimeoutQC: utils.Some(timeoutQC)}, + spec: i.spec, + } // Verify: view advanced to (6, 1) require.Equal(t, types.View{Index: 6, Number: 1}, newInner.View(), "view should advance to (6, 1)") @@ -1159,19 +1109,19 @@ func TestPushCommitQC_RotatesEpochAtBoundary(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) s := newConsensusState(t, registry, keys[0]) - require.Equal(t, types.EpochIndex(0), s.innerRecv.Load().epoch.EpochIndex()) + require.Equal(t, types.EpochIndex(0), s.innerRecv.Load().spec.Epoch.EpochIndex()) ep0, ok := registry.EpochByIndex(0) require.True(t, ok) qc := commitQCAtRoad(ep0, keys, epoch.LastRoad(0)) require.Equal(t, epoch.LastRoad(0), qc.Proposal().Index()) - // Avail resolves the next-view epoch; pushSpecFromAvail installs it verbatim. + // Avail resolves the next-view epoch; pushSpec installs it verbatim. ep1, err := registry.EpochAt(epoch.FirstRoad(1)) require.NoError(t, err) - require.NoError(t, s.pushSpecFromAvail(types.ConsensusSpec{CommitQC: utils.Some(qc), Epoch: ep1})) + require.NoError(t, s.pushSpec(types.ConsensusSpec{CommitQC: utils.Some(qc), Epoch: ep1})) got := s.innerRecv.Load() - require.Equal(t, types.EpochIndex(1), got.epoch.EpochIndex()) + require.Equal(t, types.EpochIndex(1), got.spec.Epoch.EpochIndex()) require.Equal(t, epoch.FirstRoad(1), got.View().Index) } @@ -1183,7 +1133,7 @@ func TestNewState_ErrAvailBehindConsensus(t *testing.T) { ep0, ok := registry.EpochByIndex(0) require.True(t, ok) qc := commitQCAtRoad(ep0, keys, 3) - seedPersistedInner(dir, &persistedInner{CommitQC: utils.Some(qc)}) + seedPersistedInner(dir, utils.Some(qc), &persistedInner{}) _, err := NewState(&Config{ Key: keys[0], diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go index 37c6413100..1cf238df47 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner.go @@ -15,11 +15,13 @@ import ( // # What We Persist // // All fields are persisted atomically in a single A/B file pair (inner_a.pb/inner_b.pb): -// - CommitQC: justified entering the current index +// - CommitQCIndex: tip road index used to validate the WAL against ConsensusSpec // - PrepareQC: needed for timeoutVote on restart // - TimeoutQC: justified entering the current view number // - CommitVote, PrepareVote, TimeoutVote: this node's votes for the current view // +// Runtime CommitQC + next-view epoch live on inner.spec (ConsensusSpec), not here. +// // # Why We Persist // // Safety: Votes prevent double-voting on restart — a critical safety property. @@ -56,36 +58,54 @@ import ( // - Votes (prepareVote, commitVote, timeoutVote): YES — rebroadcast via sendUpdates // - TimeoutQC: YES — rebroadcast via myTimeoutQC watch // - CommitQC: NO — used locally for view justification but not rebroadcast; -// CommitQCs are served via StreamCommitQCs from the data layer, not from -// the persisted viewSpec. TODO: consider rebroadcasting CommitQC on restart -// to help peers sync faster after cluster-wide outages. +// the runtime tip comes from ConsensusSpec, while the WAL stores only +// CommitQCIndex. CommitQCs are served via StreamCommitQCs from the data +// layer. TODO: consider rebroadcasting CommitQC on restart to help peers +// sync faster after cluster-wide outages. type persistedInner struct { - CommitQC utils.Option[*types.CommitQC] - PrepareQC utils.Option[*types.PrepareQC] - TimeoutQC utils.Option[*types.TimeoutQC] + CommitQCIndex utils.Option[types.RoadIndex] + PrepareQC utils.Option[*types.PrepareQC] + TimeoutQC utils.Option[*types.TimeoutQC] CommitVote utils.Option[*types.Signed[*types.CommitVote]] PrepareVote utils.Option[*types.Signed[*types.PrepareVote]] TimeoutVote utils.Option[*types.FullTimeoutVote] } +// commitQCIndex returns the tip road index carried by spec, if any. +func commitQCIndex(spec types.ConsensusSpec) utils.Option[types.RoadIndex] { + if cqc, ok := spec.CommitQC.Get(); ok { + return utils.Some(cqc.Index()) + } + return utils.None[types.RoadIndex]() +} + +// nextIndexOpt returns Index+1 of idx, or 0 if absent (same as types.NextIndexOpt for a tip). +func nextIndexOpt(idx utils.Option[types.RoadIndex]) types.RoadIndex { + if i, ok := idx.Get(); ok { + return i + 1 + } + return 0 +} + // validate checks internal consistency and cryptographic signatures of persisted state. -// Returns error on corrupt state. -func (p *persistedInner) validate(ep *types.Epoch) error { +// Returns error on corrupt state. CommitQCIndex is checked against spec by newInner first. +func (p *persistedInner) validate(spec types.ConsensusSpec) error { + ep := spec.Epoch // TimeoutQC index must equal NextIndexOpt(CommitQC) (i.e., CommitQC.Index+1, or 0 if missing). // Since we persist the entire inner state atomically, a mismatched index is always corrupt. if tqc, ok := p.TimeoutQC.Get(); ok { tqcIndex := tqc.View().Index - expectedIndex := types.NextIndexOpt(p.CommitQC) + expectedIndex := types.NextIndexOpt(spec.CommitQC) 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(ep, spec.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: spec.CommitQC, TimeoutQC: p.TimeoutQC, Epoch: ep} currentView := vs.View() committee := ep.Committee() @@ -134,12 +154,21 @@ func verifyReplicaSig[T types.Msg](c *types.Committee, v *types.Signed[T]) error return v.VerifySig() } +// walTipIndex returns NextIndexOpt of the tip recorded in the WAL, if any. +func walTipIndex(p *pb.PersistedInner) types.RoadIndex { + if p == nil || p.CommitQcIndex == nil { + return 0 + } + return types.RoadIndex(*p.CommitQcIndex) + 1 +} + // innerProtoConv is a protobuf converter for persistedInner. var innerProtoConv = protoutils.Conv[*persistedInner, *pb.PersistedInner]{ Encode: func(m *persistedInner) *pb.PersistedInner { p := &pb.PersistedInner{} - if v, ok := m.CommitQC.Get(); ok { - p.CommitQc = types.CommitQCConv.Encode(v) + if idx, ok := m.CommitQCIndex.Get(); ok { + v := uint64(idx) + p.CommitQcIndex = &v } if v, ok := m.PrepareQC.Get(); ok { p.PrepareQc = types.PrepareQCConv.Encode(v) @@ -160,12 +189,8 @@ var innerProtoConv = protoutils.Conv[*persistedInner, *pb.PersistedInner]{ }, Decode: func(p *pb.PersistedInner) (*persistedInner, error) { m := &persistedInner{} - if p.CommitQc != nil { - v, err := types.CommitQCConv.Decode(p.CommitQc) - if err != nil { - return nil, fmt.Errorf("commit_qc: %w", err) - } - m.CommitQC = utils.Some(v) + if p.CommitQcIndex != nil { + m.CommitQCIndex = utils.Some(types.RoadIndex(*p.CommitQcIndex)) } if p.PrepareQc != nil { v, err := types.PrepareQCConv.Decode(p.PrepareQc) diff --git a/sei-tendermint/internal/autobahn/consensus/persisted_inner_test.go b/sei-tendermint/internal/autobahn/consensus/persisted_inner_test.go index 4acfacfd48..2e33ee3a3f 100644 --- a/sei-tendermint/internal/autobahn/consensus/persisted_inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persisted_inner_test.go @@ -5,13 +5,14 @@ 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" ) // genPersistedInner generates a random persistedInner with random optional fields. func genPersistedInner(rng utils.Rng) *persistedInner { p := &persistedInner{} if rng.Intn(2) == 1 { - p.CommitQC = utils.Some(types.GenCommitQC(rng)) + p.CommitQCIndex = utils.Some(types.RoadIndex(rng.Uint64())) } if rng.Intn(2) == 1 { p.PrepareQC = utils.Some(types.GenPrepareQC(rng)) @@ -45,3 +46,14 @@ func TestPersistedInnerConv(t *testing.T) { } } } + +func TestInnerProtoConv_WalTipIndex(t *testing.T) { + none := innerProtoConv.Encode(&persistedInner{}) + require.Nil(t, none.CommitQcIndex) + require.Equal(t, types.RoadIndex(0), walTipIndex(none)) + + withTip := innerProtoConv.Encode(&persistedInner{CommitQCIndex: utils.Some(types.RoadIndex(5))}) + require.NotNil(t, withTip.CommitQcIndex) + require.Equal(t, uint64(5), *withTip.CommitQcIndex) + require.Equal(t, types.RoadIndex(6), walTipIndex(withTip)) +} diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 0b7b3e5e1e..610a31602d 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -125,7 +125,7 @@ func newState( prepareVotes: utils.NewMutex(newPrepareVotes()), commitVotes: utils.NewMutex(newCommitVotes()), - myView: utils.NewAtomicSend(types.ViewSpec{CommitQC: initialInner.CommitQC, TimeoutQC: initialInner.TimeoutQC, Epoch: initialInner.epoch}), + myView: utils.NewAtomicSend(types.ViewSpec{CommitQC: initialInner.spec.CommitQC, TimeoutQC: initialInner.TimeoutQC, Epoch: initialInner.spec.Epoch}), myProposal: utils.NewAtomicSend(utils.None[*types.FullProposal]()), myPrepareVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqPrepareVote]()), myCommitVote: utils.NewAtomicSend(utils.None[*types.ConsensusReqCommitVote]()), @@ -273,7 +273,7 @@ func updateOutput[T types.ConsensusReq](w *utils.AtomicSend[utils.Option[T]], v // timers, neither of which constitutes a vote. func (s *State) runOutputs(ctx context.Context) error { return s.innerRecv.Iter(ctx, func(ctx context.Context, i inner) error { - vs := types.ViewSpec{CommitQC: i.CommitQC, TimeoutQC: i.TimeoutQC, Epoch: i.epoch} + vs := types.ViewSpec{CommitQC: i.spec.CommitQC, TimeoutQC: i.TimeoutQC, Epoch: i.spec.Epoch} old := s.myView.Load() if old.View().Less(vs.View()) { s.myView.Store(vs) @@ -317,12 +317,12 @@ func (s *State) Run(ctx context.Context) error { return nil }) }) - scope.SpawnNamed("pushSpecFromAvail", func() error { + scope.SpawnNamed("pushSpec", func() error { // We pull the tip back from "avail" for dissemination. This ensures we // only advance on CommitQCs that avail has verified, logged, and paired // with the epoch of the next view — consensus resolves no epochs itself. return s.avail.SubscribeConsensusSpec().Iter(ctx, func(ctx context.Context, spec types.ConsensusSpec) error { - return s.pushSpecFromAvail(spec) + return s.pushSpec(spec) }) }) scope.SpawnNamed("pushPrepareQC", func() error { diff --git a/sei-tendermint/internal/autobahn/pb/autobahn.pb.go b/sei-tendermint/internal/autobahn/pb/autobahn.pb.go index 61eaa147ff..dcb9cb6bd5 100644 --- a/sei-tendermint/internal/autobahn/pb/autobahn.pb.go +++ b/sei-tendermint/internal/autobahn/pb/autobahn.pb.go @@ -1356,13 +1356,15 @@ func (x *FullTimeoutVote) GetLatestPrepareQc() *PrepareQC { // Do NOT persist internal derived fields (e.g., cached computations, runtime state). // Derived fields are implementation-dependent and should be recomputed on load. type PersistedInner struct { - state protoimpl.MessageState `protogen:"open.v1"` - CommitQc *CommitQC `protobuf:"bytes,1,opt,name=commit_qc,json=commitQc,proto3,oneof" json:"commit_qc,omitempty"` - PrepareQc *PrepareQC `protobuf:"bytes,2,opt,name=prepare_qc,json=prepareQc,proto3,oneof" json:"prepare_qc,omitempty"` - TimeoutQc *TimeoutQC `protobuf:"bytes,3,opt,name=timeout_qc,json=timeoutQc,proto3,oneof" json:"timeout_qc,omitempty"` - CommitVoteV2 *SignedProposal `protobuf:"bytes,7,opt,name=commit_vote_v2,json=commitVoteV2,proto3,oneof" json:"commit_vote_v2,omitempty"` - PrepareVoteV2 *SignedProposal `protobuf:"bytes,8,opt,name=prepare_vote_v2,json=prepareVoteV2,proto3,oneof" json:"prepare_vote_v2,omitempty"` - TimeoutVote *FullTimeoutVote `protobuf:"bytes,6,opt,name=timeout_vote,json=timeoutVote,proto3,oneof" json:"timeout_vote,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // Tip CommitQC road index for ErrAvailBehindConsensus on restore. + // Absent means no tip yet (genesis view 0). Runtime tip comes from ConsensusSpec. + CommitQcIndex *uint64 `protobuf:"varint,9,opt,name=commit_qc_index,json=commitQcIndex,proto3,oneof" json:"commit_qc_index,omitempty"` + PrepareQc *PrepareQC `protobuf:"bytes,2,opt,name=prepare_qc,json=prepareQc,proto3,oneof" json:"prepare_qc,omitempty"` + TimeoutQc *TimeoutQC `protobuf:"bytes,3,opt,name=timeout_qc,json=timeoutQc,proto3,oneof" json:"timeout_qc,omitempty"` + CommitVoteV2 *SignedProposal `protobuf:"bytes,7,opt,name=commit_vote_v2,json=commitVoteV2,proto3,oneof" json:"commit_vote_v2,omitempty"` + PrepareVoteV2 *SignedProposal `protobuf:"bytes,8,opt,name=prepare_vote_v2,json=prepareVoteV2,proto3,oneof" json:"prepare_vote_v2,omitempty"` + TimeoutVote *FullTimeoutVote `protobuf:"bytes,6,opt,name=timeout_vote,json=timeoutVote,proto3,oneof" json:"timeout_vote,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1397,11 +1399,11 @@ func (*PersistedInner) Descriptor() ([]byte, []int) { return file_autobahn_autobahn_proto_rawDescGZIP(), []int{23} } -func (x *PersistedInner) GetCommitQc() *CommitQC { - if x != nil { - return x.CommitQc +func (x *PersistedInner) GetCommitQcIndex() uint64 { + if x != nil && x.CommitQcIndex != nil { + return *x.CommitQcIndex } - return nil + return 0 } func (x *PersistedInner) GetPrepareQc() *PrepareQC { @@ -2385,23 +2387,22 @@ const file_autobahn_autobahn_proto_rawDesc = "" + "\x0fFullTimeoutVote\x124\n" + "\avote_v2\x18\x03 \x01(\v2\x1b.autobahn.SignedTimeoutVoteR\x06voteV2\x12D\n" + "\x11latest_prepare_qc\x18\x02 \x01(\v2\x13.autobahn.PrepareQCH\x00R\x0flatestPrepareQc\x88\x01\x01:\x06\xe8\x88\xe2\xab\f\x01B\x14\n" + - "\x12_latest_prepare_qcJ\x04\b\x01\x10\x02R\x04vote\"\x92\x04\n" + - "\x0ePersistedInner\x124\n" + - "\tcommit_qc\x18\x01 \x01(\v2\x12.autobahn.CommitQCH\x00R\bcommitQc\x88\x01\x01\x127\n" + + "\x12_latest_prepare_qcJ\x04\b\x01\x10\x02R\x04vote\"\xa0\x04\n" + + "\x0ePersistedInner\x12+\n" + + "\x0fcommit_qc_index\x18\t \x01(\x04H\x00R\rcommitQcIndex\x88\x01\x01\x127\n" + "\n" + "prepare_qc\x18\x02 \x01(\v2\x13.autobahn.PrepareQCH\x01R\tprepareQc\x88\x01\x01\x127\n" + "\n" + "timeout_qc\x18\x03 \x01(\v2\x13.autobahn.TimeoutQCH\x02R\ttimeoutQc\x88\x01\x01\x12C\n" + "\x0ecommit_vote_v2\x18\a \x01(\v2\x18.autobahn.SignedProposalH\x03R\fcommitVoteV2\x88\x01\x01\x12E\n" + "\x0fprepare_vote_v2\x18\b \x01(\v2\x18.autobahn.SignedProposalH\x04R\rprepareVoteV2\x88\x01\x01\x12A\n" + - "\ftimeout_vote\x18\x06 \x01(\v2\x19.autobahn.FullTimeoutVoteH\x05R\vtimeoutVote\x88\x01\x01B\f\n" + - "\n" + - "_commit_qcB\r\n" + + "\ftimeout_vote\x18\x06 \x01(\v2\x19.autobahn.FullTimeoutVoteH\x05R\vtimeoutVote\x88\x01\x01B\x12\n" + + "\x10_commit_qc_indexB\r\n" + "\v_prepare_qcB\r\n" + "\v_timeout_qcB\x11\n" + "\x0f_commit_vote_v2B\x12\n" + "\x10_prepare_vote_v2B\x0f\n" + - "\r_timeout_voteJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\vcommit_voteR\fprepare_vote\"\x97\x01\n" + + "\r_timeout_voteJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06J\x04\b\x01\x10\x02R\vcommit_voteR\fprepare_voteR\tcommit_qc\"\x97\x01\n" + "\x19PersistedAvailPruneAnchor\x12+\n" + "\x06app_qc\x18\x01 \x01(\v2\x0f.autobahn.AppQCH\x00R\x05appQc\x88\x01\x01\x124\n" + "\tcommit_qc\x18\x02 \x01(\v2\x12.autobahn.CommitQCH\x01R\bcommitQc\x88\x01\x01B\t\n" + @@ -2545,45 +2546,44 @@ var file_autobahn_autobahn_proto_depIdxs = []int32{ 17, // 28: autobahn.TimeoutQC.latest_prepare_qc:type_name -> autobahn.PrepareQC 29, // 29: autobahn.FullTimeoutVote.vote_v2:type_name -> autobahn.SignedTimeoutVote 17, // 30: autobahn.FullTimeoutVote.latest_prepare_qc:type_name -> autobahn.PrepareQC - 18, // 31: autobahn.PersistedInner.commit_qc:type_name -> autobahn.CommitQC - 17, // 32: autobahn.PersistedInner.prepare_qc:type_name -> autobahn.PrepareQC - 21, // 33: autobahn.PersistedInner.timeout_qc:type_name -> autobahn.TimeoutQC - 28, // 34: autobahn.PersistedInner.commit_vote_v2:type_name -> autobahn.SignedProposal - 28, // 35: autobahn.PersistedInner.prepare_vote_v2:type_name -> autobahn.SignedProposal - 22, // 36: autobahn.PersistedInner.timeout_vote:type_name -> autobahn.FullTimeoutVote - 25, // 37: autobahn.PersistedAvailPruneAnchor.app_qc:type_name -> autobahn.AppQC - 18, // 38: autobahn.PersistedAvailPruneAnchor.commit_qc:type_name -> autobahn.CommitQC - 26, // 39: autobahn.AppQC.vote:type_name -> autobahn.AppProposal - 8, // 40: autobahn.AppQC.sigs:type_name -> autobahn.Signature - 11, // 41: autobahn.Msg.lane_proposal:type_name -> autobahn.Block - 9, // 42: autobahn.Msg.lane_vote:type_name -> autobahn.BlockHeader - 15, // 43: autobahn.Msg.proposal:type_name -> autobahn.Proposal - 15, // 44: autobahn.Msg.prepare_vote:type_name -> autobahn.Proposal - 15, // 45: autobahn.Msg.commit_vote:type_name -> autobahn.Proposal - 20, // 46: autobahn.Msg.timeout_vote:type_name -> autobahn.TimeoutVote - 26, // 47: autobahn.Msg.app_vote:type_name -> autobahn.AppProposal - 15, // 48: autobahn.SignedProposal.msg:type_name -> autobahn.Proposal - 8, // 49: autobahn.SignedProposal.sig:type_name -> autobahn.Signature - 20, // 50: autobahn.SignedTimeoutVote.msg:type_name -> autobahn.TimeoutVote - 8, // 51: autobahn.SignedTimeoutVote.sig:type_name -> autobahn.Signature - 26, // 52: autobahn.SignedAppVote.msg:type_name -> autobahn.AppProposal - 8, // 53: autobahn.SignedAppVote.sig:type_name -> autobahn.Signature - 11, // 54: autobahn.SignedBlock.msg:type_name -> autobahn.Block - 8, // 55: autobahn.SignedBlock.sig:type_name -> autobahn.Signature - 9, // 56: autobahn.SignedBlockHeader.msg:type_name -> autobahn.BlockHeader - 8, // 57: autobahn.SignedBlockHeader.sig:type_name -> autobahn.Signature - 26, // 58: autobahn.SignedAppProposal.msg:type_name -> autobahn.AppProposal - 8, // 59: autobahn.SignedAppProposal.sig:type_name -> autobahn.Signature - 16, // 60: autobahn.ConsensusReq.proposal:type_name -> autobahn.FullProposal - 28, // 61: autobahn.ConsensusReq.prepare_vote_v2:type_name -> autobahn.SignedProposal - 28, // 62: autobahn.ConsensusReq.commit_vote_v2:type_name -> autobahn.SignedProposal - 22, // 63: autobahn.ConsensusReq.timeout_vote:type_name -> autobahn.FullTimeoutVote - 21, // 64: autobahn.ConsensusReq.timeout_qc:type_name -> autobahn.TimeoutQC - 65, // [65:65] is the sub-list for method output_type - 65, // [65:65] is the sub-list for method input_type - 65, // [65:65] is the sub-list for extension type_name - 65, // [65:65] is the sub-list for extension extendee - 0, // [0:65] is the sub-list for field type_name + 17, // 31: autobahn.PersistedInner.prepare_qc:type_name -> autobahn.PrepareQC + 21, // 32: autobahn.PersistedInner.timeout_qc:type_name -> autobahn.TimeoutQC + 28, // 33: autobahn.PersistedInner.commit_vote_v2:type_name -> autobahn.SignedProposal + 28, // 34: autobahn.PersistedInner.prepare_vote_v2:type_name -> autobahn.SignedProposal + 22, // 35: autobahn.PersistedInner.timeout_vote:type_name -> autobahn.FullTimeoutVote + 25, // 36: autobahn.PersistedAvailPruneAnchor.app_qc:type_name -> autobahn.AppQC + 18, // 37: autobahn.PersistedAvailPruneAnchor.commit_qc:type_name -> autobahn.CommitQC + 26, // 38: autobahn.AppQC.vote:type_name -> autobahn.AppProposal + 8, // 39: autobahn.AppQC.sigs:type_name -> autobahn.Signature + 11, // 40: autobahn.Msg.lane_proposal:type_name -> autobahn.Block + 9, // 41: autobahn.Msg.lane_vote:type_name -> autobahn.BlockHeader + 15, // 42: autobahn.Msg.proposal:type_name -> autobahn.Proposal + 15, // 43: autobahn.Msg.prepare_vote:type_name -> autobahn.Proposal + 15, // 44: autobahn.Msg.commit_vote:type_name -> autobahn.Proposal + 20, // 45: autobahn.Msg.timeout_vote:type_name -> autobahn.TimeoutVote + 26, // 46: autobahn.Msg.app_vote:type_name -> autobahn.AppProposal + 15, // 47: autobahn.SignedProposal.msg:type_name -> autobahn.Proposal + 8, // 48: autobahn.SignedProposal.sig:type_name -> autobahn.Signature + 20, // 49: autobahn.SignedTimeoutVote.msg:type_name -> autobahn.TimeoutVote + 8, // 50: autobahn.SignedTimeoutVote.sig:type_name -> autobahn.Signature + 26, // 51: autobahn.SignedAppVote.msg:type_name -> autobahn.AppProposal + 8, // 52: autobahn.SignedAppVote.sig:type_name -> autobahn.Signature + 11, // 53: autobahn.SignedBlock.msg:type_name -> autobahn.Block + 8, // 54: autobahn.SignedBlock.sig:type_name -> autobahn.Signature + 9, // 55: autobahn.SignedBlockHeader.msg:type_name -> autobahn.BlockHeader + 8, // 56: autobahn.SignedBlockHeader.sig:type_name -> autobahn.Signature + 26, // 57: autobahn.SignedAppProposal.msg:type_name -> autobahn.AppProposal + 8, // 58: autobahn.SignedAppProposal.sig:type_name -> autobahn.Signature + 16, // 59: autobahn.ConsensusReq.proposal:type_name -> autobahn.FullProposal + 28, // 60: autobahn.ConsensusReq.prepare_vote_v2:type_name -> autobahn.SignedProposal + 28, // 61: autobahn.ConsensusReq.commit_vote_v2:type_name -> autobahn.SignedProposal + 22, // 62: autobahn.ConsensusReq.timeout_vote:type_name -> autobahn.FullTimeoutVote + 21, // 63: autobahn.ConsensusReq.timeout_qc:type_name -> autobahn.TimeoutQC + 64, // [64:64] is the sub-list for method output_type + 64, // [64:64] is the sub-list for method input_type + 64, // [64:64] is the sub-list for extension type_name + 64, // [64:64] is the sub-list for extension extendee + 0, // [0:64] is the sub-list for field type_name } func init() { file_autobahn_autobahn_proto_init() } diff --git a/sei-tendermint/internal/autobahn/pb/autobahn.wireguard.go b/sei-tendermint/internal/autobahn/pb/autobahn.wireguard.go index 366e741931..38fea05046 100644 --- a/sei-tendermint/internal/autobahn/pb/autobahn.wireguard.go +++ b/sei-tendermint/internal/autobahn/pb/autobahn.wireguard.go @@ -275,7 +275,7 @@ func init() { // Register the wireguard.Schema generated for autobahn.PersistedInner. runtime.MustRegister[*PersistedInner](runtime.Schema{ - 1: {MaxCount: 1, Nested: utils.Some(reflect.TypeFor[*CommitQC]())}, + 9: {MaxCount: 1}, 2: {MaxCount: 1, Nested: utils.Some(reflect.TypeFor[*PrepareQC]())}, 3: {MaxCount: 1, Nested: utils.Some(reflect.TypeFor[*TimeoutQC]())}, 7: {MaxCount: 1, Nested: utils.Some(reflect.TypeFor[*SignedProposal]())}, From a026879ccebb19e1dbe94ef8c5aaa4f1eb1137f2 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 18 Aug 2026 21:34:52 -0700 Subject: [PATCH 08/19] refactor(autobahn): reweightVotes from applied i.epoch Stop passing ep into reweightVotes; installEpoch stores then reweights under i.epoch, and document that laneQC / i.epoch stay distinct from the withheld consensusSpec.Epoch. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/inner.go | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 9585baebd5..c82f758b20 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -17,7 +17,8 @@ type inner struct { roads *queue[types.RoadIndex, *road] // epoch is the applied (next-CommitQC) epoch. installEpoch is the sole - // writer after construction. + // writer after construction. Distinct from consensusSpec.Epoch, which is the + // next-view epoch paired with the publishable tip and may lag while withheld. epoch utils.AtomicSend[*types.Epoch] // anchorEpoch is the epoch of data's Anchor CommitQC when one exists. // None until the first Anchor arrives (construction prune or runEvict). @@ -151,8 +152,11 @@ func (i *inner) installEpoch(ep *types.Epoch) { for lane := range ep.Committee().Lanes().All() { i.addLane(lane) } - i.reweightVotes(ep) + // Publish applied epoch before reweight so reweightVotes reads i.epoch. + // Callers hold the avail lock, so Epoch() waiters cannot observe votes + // between the Store and the reweight. i.epoch.Store(ep) + i.reweightVotes() i.refreshConsensusSpec() } @@ -252,6 +256,8 @@ func (i *inner) dropLanes(lanes []types.LaneID) int { return n } +// laneQC returns the LaneQC for (lane, n) under the applied epoch's vote +// weighting (i.epoch), if one has formed. func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { votes, ok := i.votes[lane] if !ok { @@ -264,7 +270,9 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ return entry.qc } -func (i *inner) reweightVotes(ep *types.Epoch) { +// reweightVotes recounts retained block votes under the applied epoch (i.epoch). +func (i *inner) reweightVotes() { + ep := i.epoch.Load() for _, vq := range i.votes { for n := vq.first; n < vq.next; n++ { vq.q[n].reweight(ep) From a1b37336df36a56c8eef47681a7d0327ba0957bd Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 18 Aug 2026 21:55:03 -0700 Subject: [PATCH 09/19] docs(autobahn): RoadIndex wording and Anchor epoch docs Replace ConsensusSpec "view" with RoadIndex, drop verify-epoch phrasing, and document that anchorEpoch may lead applied while prune does not advance i.epoch. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 8 ++++---- .../internal/autobahn/avail/inner.go | 20 ++++++++++++------- .../internal/autobahn/avail/state.go | 2 +- .../internal/autobahn/consensus/state.go | 2 +- .../internal/autobahn/data/state.go | 4 ++-- .../internal/autobahn/epoch/registry.go | 2 +- 6 files changed, 22 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index aef80744d0..3c28b14764 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -125,10 +125,10 @@ func (v View) Next() View { return v } -// ConsensusSpec is the durable CommitQC tip paired with the epoch of the view -// that follows it. CommitQC is None before the first tip; until then Epoch is -// genesis epoch 0 (and FirstBlock is the next global block). Consensus installs -// a spec verbatim. +// ConsensusSpec is the durable CommitQC tip paired with the epoch of the +// RoadIndex that follows it. CommitQC is None before the first tip; until then +// Epoch is genesis epoch 0 (and FirstBlock is the next global block). Consensus +// installs a spec verbatim. type ConsensusSpec struct { CommitQC utils.Option[*CommitQC] Epoch *Epoch diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index c82f758b20..30050b0ac6 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -18,10 +18,15 @@ type inner struct { // epoch is the applied (next-CommitQC) epoch. installEpoch is the sole // writer after construction. Distinct from consensusSpec.Epoch, which is the - // next-view epoch paired with the publishable tip and may lag while withheld. + // epoch of the RoadIndex after the publishable tip and may lag while withheld. + // blockVotes are always weighted under this epoch. epoch utils.AtomicSend[*types.Epoch] // anchorEpoch is the epoch of data's Anchor CommitQC when one exists. // None until the first Anchor arrives (construction prune or runEvict). + // It may exceed the applied epoch while runEpochAdvance is parked on + // WaitForEpoch, or briefly between prune and the next install: admission + // falls back to the Anchor committee via epochForVote / epochForLane. + // prune never advances i.epoch — only installEpoch does. anchorEpoch utils.Option[*types.Epoch] blocks map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] votes map[types.LaneID]*queue[types.BlockNumber, *blockVotes] @@ -99,7 +104,7 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { if i.roads.Len() > 0 { last := i.roads.q[i.roads.next-1] i.persistedCommitQC.Store(utils.Some(last.commitQC)) - // Floor applied at the durable tip's verify-epoch. Bare Store on + // Floor applied at the durable tip's epoch. Bare Store on // purpose: this is a rewind from LatestEpoch, not an install. // The install loop below re-drives it from the durable leashes. i.epoch.Store(last.epoch) @@ -190,14 +195,14 @@ func (i *inner) installReadyEpochs(ds *data.State) error { } // refreshConsensusSpec publishes ConsensusSpec for the durable tip, paired with -// the epoch of the view that follows it. The spec is withheld — the previously -// published one stands — until that epoch is applied and resolvable. +// the epoch of the RoadIndex that follows it. The spec is withheld — the +// previously published one stands — until that epoch is applied and resolvable. // // Withholding rather than publishing an earlier tip is what keeps the spec // monotonic. At an epoch boundary the durable tip sits on LastRoad(E) while // applied is still E, and a node that already entered E+1 before a restart must // not be handed a predecessor of the tip it holds: installing it would roll the -// view backwards and discard that view's votes. +// tip backwards and discard that view's votes. func (i *inner) refreshConsensusSpec() { tip := i.persistedCommitQC.Load() cqc, ok := tip.Get() @@ -222,7 +227,7 @@ func (i *inner) epochForRoad(road types.RoadIndex) utils.Option[*types.Epoch] { if road >= i.roads.first && road < i.roads.next { return utils.Some(i.roads.q[road].epoch) } - // Persist may lag installEpoch: tip's next view can sit in an earlier epoch + // Persist may lag installEpoch: tip's next RoadIndex can sit in an earlier epoch // still present on some admitted road. for idx := i.roads.first; idx < i.roads.next; idx++ { if ep := i.roads.q[idx].epoch; ep.RoadRange().Has(road) { @@ -281,7 +286,8 @@ func (i *inner) reweightVotes() { } // prune advances the state up to the data Anchor and drops lanes closed as of -// anchor.Epoch. Returns the number of lanes dropped. +// anchor.Epoch. It updates anchorEpoch only — applied epoch catch-up is left to +// installReadyEpochs / runEpochAdvance. Returns the number of lanes dropped. func (i *inner) prune(anchor data.Anchor) int { anchorEpoch := anchor.Epoch idx := anchor.CommitQC.Index() diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 648de37adf..10862d2e72 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -173,7 +173,7 @@ func (s *State) LastCommitQC() utils.AtomicRecv[utils.Option[*types.CommitQC]] { } // SubscribeConsensusSpec returns a receiver of the durable CommitQC tip paired -// with the epoch governing the view that follows it. CommitQC is None before +// with the epoch of the RoadIndex that follows it. CommitQC is None before // the first tip; until then Epoch is genesis epoch 0. func (s *State) SubscribeConsensusSpec() utils.AtomicRecv[types.ConsensusSpec] { for inner := range s.inner.Lock() { diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 610a31602d..a4f16446a5 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -320,7 +320,7 @@ func (s *State) Run(ctx context.Context) error { scope.SpawnNamed("pushSpec", func() error { // We pull the tip back from "avail" for dissemination. This ensures we // only advance on CommitQCs that avail has verified, logged, and paired - // with the epoch of the next view — consensus resolves no epochs itself. + // with the epoch of the next RoadIndex — consensus resolves no epochs itself. return s.avail.SubscribeConsensusSpec().Iter(ctx, func(ctx context.Context, spec types.ConsensusSpec) error { return s.pushSpec(spec) }) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index bc5d86b490..f474fe4d21 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -680,7 +680,7 @@ func (s *State) PushAppHash(ctx context.Context, n types.GlobalBlockNumber, hash s.metrics.NextBlock.Execute.Set(utils.Clamp[int64](inner.nextAppProposal)) // Seed cursor: at LastRoad(N) register N+1 so runEpochAdvance can install // it once seal and the prune/execution leashes are met. N+2 is not needed — - // ConsensusSpec withholds the view after LastRoad(N+1) until this fires + // ConsensusSpec withholds the RoadIndex after LastRoad(N+1) until this fires // again. s.cfg.Registry.AdvanceIfNeeded(p.Index()) // Idle boundary: no further CommitQC will republish after registration. @@ -760,7 +760,7 @@ func (s *State) AppQC(ctx context.Context, n types.GlobalBlockNumber) (*types.Ap type Anchor struct { CommitQC *types.CommitQC AppQC *types.AppQC - // Epoch is the verify-epoch of CommitQC, stashed at admit. + // Epoch of CommitQC, stashed at admit. Epoch *types.Epoch } diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index e454bc17c3..2b05b6a161 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -190,7 +190,7 @@ func (r *Registry) ensureAround(s *registryState, road types.RoadIndex) { // AdvanceIfNeeded registers epoch M+1 when roadIndex is LastRoad(M). // M+2 is not seeded: tip may race to LastRoad(M+1) before AppQC, but -// ConsensusSpec withholds that next view until M+1's AppQC boundary fires +// ConsensusSpec withholds that next RoadIndex until M+1's AppQC boundary fires // AdvanceIfNeeded again. func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { tipEpoch := IndexForRoad(roadIndex) From afd119941f84f1e3ae774f282ad5aa8e15e8975a Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 18 Aug 2026 22:00:47 -0700 Subject: [PATCH 10/19] refactor(autobahn): use advance consistently for epoch transitions Rename install/leashesMet/waitUntilApplied to advanceEpoch, canAdvanceEpoch, and waitUntilAdvanced so epoch verbs match runEpochAdvance and pushSpec. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/proposal.go | 2 +- .../internal/autobahn/avail/inner.go | 38 +++++++++---------- .../internal/autobahn/avail/inner_test.go | 18 ++++----- .../internal/autobahn/avail/state.go | 14 +++---- .../internal/autobahn/avail/state_test.go | 6 +-- .../internal/autobahn/avail/testonly.go | 2 +- .../internal/autobahn/consensus/inner.go | 8 ++-- .../internal/autobahn/consensus/inner_test.go | 4 +- .../internal/autobahn/data/state.go | 2 +- 9 files changed, 47 insertions(+), 47 deletions(-) diff --git a/sei-tendermint/autobahn/types/proposal.go b/sei-tendermint/autobahn/types/proposal.go index 3c28b14764..da6dcdd950 100644 --- a/sei-tendermint/autobahn/types/proposal.go +++ b/sei-tendermint/autobahn/types/proposal.go @@ -128,7 +128,7 @@ func (v View) Next() View { // ConsensusSpec is the durable CommitQC tip paired with the epoch of the // RoadIndex that follows it. CommitQC is None before the first tip; until then // Epoch is genesis epoch 0 (and FirstBlock is the next global block). Consensus -// installs a spec verbatim. +// advances with a spec verbatim. type ConsensusSpec struct { CommitQC utils.Option[*CommitQC] Epoch *Epoch diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 30050b0ac6..b32954cc37 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -16,7 +16,7 @@ type inner struct { consensusSpec utils.AtomicSend[types.ConsensusSpec] roads *queue[types.RoadIndex, *road] - // epoch is the applied (next-CommitQC) epoch. installEpoch is the sole + // epoch is the applied (next-CommitQC) epoch. advanceEpoch is the sole // writer after construction. Distinct from consensusSpec.Epoch, which is the // epoch of the RoadIndex after the publishable tip and may lag while withheld. // blockVotes are always weighted under this epoch. @@ -24,9 +24,9 @@ type inner struct { // anchorEpoch is the epoch of data's Anchor CommitQC when one exists. // None until the first Anchor arrives (construction prune or runEvict). // It may exceed the applied epoch while runEpochAdvance is parked on - // WaitForEpoch, or briefly between prune and the next install: admission + // WaitForEpoch, or briefly between prune and the next advance: admission // falls back to the Anchor committee via epochForVote / epochForLane. - // prune never advances i.epoch — only installEpoch does. + // prune never advances i.epoch — only advanceEpoch does. anchorEpoch utils.Option[*types.Epoch] blocks map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] votes map[types.LaneID]*queue[types.BlockNumber, *blockVotes] @@ -105,8 +105,8 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { last := i.roads.q[i.roads.next-1] i.persistedCommitQC.Store(utils.Some(last.commitQC)) // Floor applied at the durable tip's epoch. Bare Store on - // purpose: this is a rewind from LatestEpoch, not an install. - // The install loop below re-drives it from the durable leashes. + // purpose: this is a rewind from LatestEpoch, not an advanceEpoch. + // The advance loop below re-drives it from the durable leashes. i.epoch.Store(last.epoch) } @@ -142,18 +142,18 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { } i.nextBlockToPersist[lane] = q.next } - // Restart catch-up: install every epoch the durable leashes already allow. - // The live path (runEpochAdvance) installs one waited-for epoch at a time. - if err := i.installReadyEpochs(ds); err != nil { + // Restart catch-up: advance every epoch the durable leashes already allow. + // The live path (runEpochAdvance) advances one waited-for epoch at a time. + if err := i.advanceReadyEpochs(ds); err != nil { return nil, err } i.refreshConsensusSpec() return i, nil } -// installEpoch makes ep the applied epoch: opens its lanes, reweights votes, +// advanceEpoch makes ep the applied epoch: opens its lanes, reweights votes, // and republishes ConsensusSpec. -func (i *inner) installEpoch(ep *types.Epoch) { +func (i *inner) advanceEpoch(ep *types.Epoch) { for lane := range ep.Committee().Lanes().All() { i.addLane(lane) } @@ -165,12 +165,12 @@ func (i *inner) installEpoch(ep *types.Epoch) { i.refreshConsensusSpec() } -// leashesMet reports whether the applied epoch is sealed and its prune leash is +// canAdvanceEpoch reports whether the applied epoch is sealed and its prune leash is // met. Sealed means roads hold the epoch's last CommitQC. The prune leash is met // when the Anchor epoch covers the applied epoch (an AppQC for that epoch // exists). The execution leash — registry contains the next epoch — is checked // separately so live waiters are not parked on avail's lock for a registry update. -func (i *inner) leashesMet() bool { +func (i *inner) canAdvanceEpoch() bool { ep := i.epoch.Load() if i.roads.next < ep.RoadRange().Next { return false @@ -179,17 +179,17 @@ func (i *inner) leashesMet() bool { return ok && ae.EpochIndex() >= ep.EpochIndex() } -// installReadyEpochs installs every epoch whose seal and prune leashes are +// advanceReadyEpochs advances to every epoch whose seal and prune leashes are // already met. A missing next registry epoch in that state is an invariant // violation (execution leash should already have registered it). -func (i *inner) installReadyEpochs(ds *data.State) error { - for i.leashesMet() { +func (i *inner) advanceReadyEpochs(ds *data.State) error { + for i.canAdvanceEpoch() { nextIdx := i.epoch.Load().EpochIndex() + 1 next, ok := ds.Registry().EpochByIndex(nextIdx) if !ok { return fmt.Errorf("epoch %d not registered with seal+prune leashes met", nextIdx) } - i.installEpoch(next) + i.advanceEpoch(next) } return nil } @@ -201,7 +201,7 @@ func (i *inner) installReadyEpochs(ds *data.State) error { // Withholding rather than publishing an earlier tip is what keeps the spec // monotonic. At an epoch boundary the durable tip sits on LastRoad(E) while // applied is still E, and a node that already entered E+1 before a restart must -// not be handed a predecessor of the tip it holds: installing it would roll the +// not be handed a predecessor of the tip it holds: advancing to it would roll the // tip backwards and discard that view's votes. func (i *inner) refreshConsensusSpec() { tip := i.persistedCommitQC.Load() @@ -227,7 +227,7 @@ func (i *inner) epochForRoad(road types.RoadIndex) utils.Option[*types.Epoch] { if road >= i.roads.first && road < i.roads.next { return utils.Some(i.roads.q[road].epoch) } - // Persist may lag installEpoch: tip's next RoadIndex can sit in an earlier epoch + // Persist may lag advanceEpoch: tip's next RoadIndex can sit in an earlier epoch // still present on some admitted road. for idx := i.roads.first; idx < i.roads.next; idx++ { if ep := i.roads.q[idx].epoch; ep.RoadRange().Has(road) { @@ -287,7 +287,7 @@ func (i *inner) reweightVotes() { // prune advances the state up to the data Anchor and drops lanes closed as of // anchor.Epoch. It updates anchorEpoch only — applied epoch catch-up is left to -// installReadyEpochs / runEpochAdvance. Returns the number of lanes dropped. +// advanceReadyEpochs / runEpochAdvance. Returns the number of lanes dropped. func (i *inner) prune(anchor data.Anchor) int { anchorEpoch := anchor.Epoch idx := anchor.CommitQC.Index() diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index d8dc2bf616..60138703e6 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -223,12 +223,12 @@ func TestAddLane_ReportsNewLaneForEachMembershipPeriod(t *testing.T) { require.True(t, i.addLane(types.LaneID{Validator: a.Public(), Joined: 3})) } -// TestInstallReadyEpochs_BoundaryTipUsesDataAppQC: tip at LastRoad(0) with +// TestAdvanceReadyEpochs_BoundaryTipUsesDataAppQC: tip at LastRoad(0) with // applied floored to 0 (restart), data's Anchor already covers epoch 0, registry -// has epoch 1 → install walks to 1 so ConsensusSpec republishes the tip. +// has epoch 1 → advance walks to 1 so ConsensusSpec republishes the tip. // This is the avail half of the blind-Spec restore invariant: consensus may // refuse to start if Spec stays behind a WAL tip at LastRoad(0) after catch-up. -func TestInstallReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { +func TestAdvanceReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) @@ -296,8 +296,8 @@ func TestInstallReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { i.epoch.Store(ep0) require.False(t, i.roads.q[last].appQC.IsPresent(), "road AppQC empty; prune leash is the Anchor") - require.True(t, i.leashesMet()) - require.NoError(t, i.installReadyEpochs(ds)) + require.True(t, i.canAdvanceEpoch()) + require.NoError(t, i.advanceReadyEpochs(ds)) require.Equal(t, ep1.EpochIndex(), i.epoch.Load().EpochIndex()) spec := i.consensusSpec.Load() @@ -307,7 +307,7 @@ func TestInstallReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { require.Equal(t, types.EpochIndex(1), spec.Epoch.EpochIndex()) } -func TestInstallReadyEpochs_MissingNextEpochErrors(t *testing.T) { +func TestAdvanceReadyEpochs_MissingNextEpochErrors(t *testing.T) { rng := utils.TestRng() // Fresh registry has epochs 0 and 1; seal epoch 1 so the next lookup is 2. registry, keys := epoch.GenRegistry(rng, 3) @@ -341,8 +341,8 @@ func TestInstallReadyEpochs_MissingNextEpochErrors(t *testing.T) { i.roads.pushBack(newRoad(qcLast, ep1)) i.persistedCommitQC.Store(utils.Some(qcLast)) - require.True(t, i.leashesMet()) - require.Error(t, i.installReadyEpochs(newTestDataState(&data.Config{Registry: registry}))) + require.True(t, i.canAdvanceEpoch()) + require.Error(t, i.advanceReadyEpochs(newTestDataState(&data.Config{Registry: registry}))) } // TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied: the durable tip @@ -383,7 +383,7 @@ func TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied(t *testing.T i.refreshConsensusSpec() require.False(t, i.consensusSpec.Load().CommitQC.IsPresent(), "spec must be withheld, not published at the predecessor") - i.installEpoch(ep1) + i.advanceEpoch(ep1) spec := i.consensusSpec.Load() cqc, ok := spec.CommitQC.Get() require.True(t, ok) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 10862d2e72..c0f9b629d1 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -220,9 +220,9 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi return qc, err } -// waitUntilApplied blocks until the applied (next-CommitQC) epoch equals i. +// waitUntilAdvanced blocks until the applied (next-CommitQC) epoch equals i. // Returns ErrPruned if applied has already passed i (see types.ErrPruned). -func (s *State) waitUntilApplied(ctx context.Context, i types.EpochIndex) (*types.Epoch, error) { +func (s *State) waitUntilAdvanced(ctx context.Context, i types.EpochIndex) (*types.Epoch, error) { epoch, err := s.epoch.Wait(ctx, func(epoch *types.Epoch) bool { return i <= epoch.EpochIndex() }) @@ -239,7 +239,7 @@ func (s *State) waitUntilApplied(ctx context.Context, i types.EpochIndex) (*type // Stale QCs are a no-op. func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { idx := qc.Proposal().Index() - epoch, err := s.waitUntilApplied(ctx, qc.Proposal().EpochIndex()) + epoch, err := s.waitUntilAdvanced(ctx, qc.Proposal().EpochIndex()) if err != nil { if errors.Is(err, types.ErrPruned) { return nil @@ -694,7 +694,7 @@ func (s *State) runEvict(ctx context.Context) error { // runEpochAdvance is the sole writer of inner.epoch after construction. It waits // for the execution leash on the registry, then seal and the prune leash on -// avail's inner watch (leashesMet), and installs one epoch per wake. +// avail's inner watch (canAdvanceEpoch), and advances one epoch per wake. func (s *State) runEpochAdvance(ctx context.Context) error { for { next := s.epoch.Load().EpochIndex() + 1 @@ -704,14 +704,14 @@ func (s *State) runEpochAdvance(ctx context.Context) error { } for inner, ctrl := range s.inner.Lock() { if err := ctrl.WaitUntil(ctx, func() bool { - return inner.leashesMet() + return inner.canAdvanceEpoch() }); err != nil { return err } if got := inner.epoch.Load().EpochIndex(); got+1 != next { - return fmt.Errorf("runEpochAdvance: applied %d, want %d before install", got, next-1) + return fmt.Errorf("runEpochAdvance: applied %d, want %d before advance", got, next-1) } - inner.installEpoch(ep) + inner.advanceEpoch(ep) ctrl.Updated() } } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 7606d26505..b18a4fe358 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -864,12 +864,12 @@ func TestWaitUntilApplied_ParksUntilEpochAdvance(t *testing.T) { var got *types.Epoch var waitErr error sc.Spawn(func() error { - got, waitErr = state.waitUntilApplied(ctx, 1) + got, waitErr = state.waitUntilAdvanced(ctx, 1) return nil }) synctest.Wait() if got != nil { - return fmt.Errorf("waitUntilApplied returned before epoch advance") + return fmt.Errorf("waitUntilAdvanced returned before epoch advance") } if err := DriveAdvance(ctx, state, keys, ep1.EpochIndex()); err != nil { @@ -880,7 +880,7 @@ func TestWaitUntilApplied_ParksUntilEpochAdvance(t *testing.T) { return waitErr } if got.EpochIndex() != 1 { - return fmt.Errorf("waitUntilApplied epoch = %d, want 1", got.EpochIndex()) + return fmt.Errorf("waitUntilAdvanced epoch = %d, want 1", got.EpochIndex()) } return nil })) diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index afa7553169..1b7b4165e1 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -107,7 +107,7 @@ func tipLink(ep *types.Epoch, key types.SecretKey, idx types.RoadIndex) *types.C } // DriveAdvance seals each applied epoch through want-1 and waits for -// runEpochAdvance to install want. The registry must already contain want; +// runEpochAdvance to advance to want. The registry must already contain want; // runEpochAdvance must be running. // Intended for tests only. func DriveAdvance(ctx context.Context, state *State, keys []types.SecretKey, want types.EpochIndex) error { diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index c6bdc6ec66..45b776008f 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -60,7 +60,7 @@ // - TimeoutQC at index > 0 without CommitQCIndex (how did we advance past index 0?) // - TimeoutQC at index > CommitQCIndex + 1 (how did we skip intermediate commits?) // - WAL tip ahead of ConsensusSpec: ErrAvailBehindConsensus -// - Spec ahead of WAL tip: install spec, discard per-view WAL state +// - Spec ahead of WAL tip: advance to spec, discard per-view WAL state // - Equal tip: keep WAL votes/QCs if persistedInner.validate(spec) passes // (e.g. reject future-view votes, TimeoutQC index ≠ NextIndexOpt(spec.CommitQC), // bad signatures, CommitVote without PrepareQC) @@ -150,9 +150,9 @@ func newInner( return inner{persistedInner: out, spec: spec}, nil } -// pushSpec installs avail's ConsensusSpec tip and clears per-view state. -// Specs that do not advance the view are ignored, which covers the tipless spec -// published before the first CommitQC. +// pushSpec advances consensus to avail's ConsensusSpec tip and clears per-view +// state. Specs that do not advance the view are ignored, which covers the +// tipless spec published before the first CommitQC. func (s *State) pushSpec(spec types.ConsensusSpec) error { specViewIdx := types.NextIndexOpt(spec.CommitQC) if specViewIdx <= s.innerRecv.Load().View().Index { diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 43a87a9e1e..e6a0c6f81a 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -211,7 +211,7 @@ func TestNewInner_EqualTipKeepsVotes(t *testing.T) { } // TestRestore_BoundaryCatchUpSpecCoversWAL is the restart invariant blind-Spec -// trust depends on. After avail catch-up installs epoch 1 at the LastRoad(0) +// trust depends on. After avail catch-up advances epoch 1 at the LastRoad(0) // tip, ConsensusSpec must republish that tip so a WAL at the same tip restores // without ErrAvailBehindConsensus and keeps anti-equivocation votes. func TestRestore_BoundaryCatchUpSpecCoversWAL(t *testing.T) { @@ -1116,7 +1116,7 @@ func TestPushCommitQC_RotatesEpochAtBoundary(t *testing.T) { qc := commitQCAtRoad(ep0, keys, epoch.LastRoad(0)) require.Equal(t, epoch.LastRoad(0), qc.Proposal().Index()) - // Avail resolves the next-view epoch; pushSpec installs it verbatim. + // Avail resolves the next-view epoch; pushSpec advances to it verbatim. ep1, err := registry.EpochAt(epoch.FirstRoad(1)) require.NoError(t, err) require.NoError(t, s.pushSpec(types.ConsensusSpec{CommitQC: utils.Some(qc), Epoch: ep1})) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index f474fe4d21..e89eaa78f9 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -678,7 +678,7 @@ func (s *State) PushAppHash(ctx context.Context, n types.GlobalBlockNumber, hash inner.nextAppProposal += 1 } s.metrics.NextBlock.Execute.Set(utils.Clamp[int64](inner.nextAppProposal)) - // Seed cursor: at LastRoad(N) register N+1 so runEpochAdvance can install + // Seed cursor: at LastRoad(N) register N+1 so runEpochAdvance can advance // it once seal and the prune/execution leashes are met. N+2 is not needed — // ConsensusSpec withholds the RoadIndex after LastRoad(N+1) until this fires // again. From 89ce753cf308bc66b06ee3cf8dce1229e2614a34 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 09:50:51 -0700 Subject: [PATCH 11/19] refactor(autobahn): match blocks by header, votes by epoch then Verify insertBlock compares the FullCommitQC header by value instead of hash. PushVote picks applied/Anchor membership first, then Verify on that committee. Co-authored-by: Cursor --- .../internal/autobahn/avail/state.go | 22 ++++++++++++------- .../internal/autobahn/data/state.go | 10 ++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index c0f9b629d1..663b42d964 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -433,7 +433,11 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return nil } // TODO: accept future-epoch joiner votes. - if !epochForVote(inner, vote).IsPresent() { + ep, ok := epochForVote(inner, vote).Get() + if !ok { + return nil + } + if err := vote.Msg().Verify(ep.Committee()); err != nil { return nil } applied := inner.epoch.Load() @@ -447,20 +451,22 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote return nil } -// epochForVote returns the applied or Anchor epoch under which vote's lane and -// signer verify. Prefers applied; falls back to Anchor when that is a different -// EpochIndex. +// epochForVote returns the applied or Anchor epoch the vote belongs to +// (lane + signer in that committee). Prefers applied; falls back to Anchor +// when that is a different EpochIndex. func epochForVote(inner *inner, vote *types.Signed[*types.LaneVote]) utils.Option[*types.Epoch] { - match := func(ep *types.Epoch) bool { + lane := vote.Msg().Header().Lane() + key := vote.Key() + belongs := func(ep *types.Epoch) bool { c := ep.Committee() - return vote.Msg().Verify(c) == nil && c.HasReplica(vote.Key()) + return c.HasLane(lane) && c.HasReplica(key) } applied := inner.epoch.Load() - if match(applied) { + if belongs(applied) { return utils.Some(applied) } ae, ok := inner.anchorEpoch.Get() - if !ok || ae.EpochIndex() == applied.EpochIndex() || !match(ae) { + if !ok || ae.EpochIndex() == applied.EpochIndex() || !belongs(ae) { return utils.None[*types.Epoch]() } return utils.Some(ae) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index e89eaa78f9..de5dafb9ae 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -178,13 +178,13 @@ func (i *inner) insertBlock(n types.GlobalBlockNumber, block *types.Block) error // nextAppProposal <= nextBlock, so qcs[n] is always present. qc := i.qcs[n].qc storedGR := qc.QC().GlobalRange() - want := qc.Headers()[n-storedGR.First].Hash() - got := block.Header().Hash() - if want != got { - return fmt.Errorf("block %d header hash mismatch: want %v, got %v", n, want, got) + want := qc.Headers()[n-storedGR.First] + got := block.Header() + if *want != *got { + return fmt.Errorf("block %d header mismatch: want %v, got %v", n, want.Hash(), got.Hash()) } i.blocks[n] = block - i.blockHashes[got] = n + i.blockHashes[got.Hash()] = n return nil } From 80ba36d977f8a82ec55427fd948cf5cc0d7e9202 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 13:53:10 -0700 Subject: [PATCH 12/19] refactor(autobahn): inline epochForRoad; note AvailSpec follow-up Fold the one-caller epoch lookup into refreshConsensusSpec and record the AvailSpec idea so avail can leave the registry in a later PR. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 42 +++++++++---------- .../internal/autobahn/data/state.go | 6 ++- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index b32954cc37..23429c46f3 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -210,31 +210,31 @@ func (i *inner) refreshConsensusSpec() { return } next := cqc.Index() + 1 - if epoch.IndexForRoad(next) > i.epoch.Load().EpochIndex() { - return - } - ep, ok := i.epochForRoad(next).Get() - if !ok { + ep := i.epoch.Load() + if epoch.IndexForRoad(next) > ep.EpochIndex() { return } - i.consensusSpec.Store(types.ConsensusSpec{CommitQC: tip, Epoch: ep}) -} - -func (i *inner) epochForRoad(road types.RoadIndex) utils.Option[*types.Epoch] { - if ep := i.epoch.Load(); ep.RoadRange().Has(road) { - return utils.Some(ep) - } - if road >= i.roads.first && road < i.roads.next { - return utils.Some(i.roads.q[road].epoch) - } - // Persist may lag advanceEpoch: tip's next RoadIndex can sit in an earlier epoch - // still present on some admitted road. - for idx := i.roads.first; idx < i.roads.next; idx++ { - if ep := i.roads.q[idx].epoch; ep.RoadRange().Has(road) { - return utils.Some(ep) + if !ep.RoadRange().Has(next) { + // Persist may lag advanceEpoch: tip's next RoadIndex can sit in an + // earlier epoch still present on some admitted road. + found := false + if next >= i.roads.first && next < i.roads.next { + ep = i.roads.q[next].epoch + found = true + } else { + for idx := i.roads.first; idx < i.roads.next; idx++ { + if r := i.roads.q[idx].epoch; r.RoadRange().Has(next) { + ep = r + found = true + break + } + } + } + if !found { + return } } - return utils.None[*types.Epoch]() + i.consensusSpec.Store(types.ConsensusSpec{CommitQC: tip, Epoch: ep}) } func (i *inner) addLane(lane types.LaneID) bool { diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index de5dafb9ae..92d27678d7 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -764,8 +764,10 @@ type Anchor struct { Epoch *types.Epoch } -// Anchor represents the AppQC/CommitQC covering inner.first. -// It is used by avail.State. +// Anchor is the AppQC/CommitQC covering inner.first. +// +// TODO: replace with AvailSpec = Option[Anchor] + nextEpoch (None + first +// epoch initially) so avail does not consult the registry. func (s *State) Anchor() utils.AtomicRecv[utils.Option[Anchor]] { for inner := range s.inner.Lock() { return inner.anchor.Subscribe() From 67bef1ec3cffaae5d967bc2fb84e033924c33b03 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 18:55:00 -0700 Subject: [PATCH 13/19] feat(autobahn): prune registry epochs behind the data Anchor Drop LatestEpoch and derive ActivateEpoch from an explicit parent so prune cannot dangle a cursor. Lookups return ErrPruned for dropped indices; PushQC treats a pruned-epoch QC as stale. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 43 +++++---- .../internal/autobahn/avail/inner_test.go | 43 ++++----- .../internal/autobahn/avail/state.go | 3 + .../internal/autobahn/avail/state_test.go | 57 +++++------ .../autobahn/avail/subscriptions_test.go | 12 ++- .../internal/autobahn/consensus/inner_test.go | 94 +++++++++---------- .../consensus/persist/commitqcs_test.go | 26 ++--- .../internal/autobahn/consensus/state_test.go | 12 +-- .../internal/autobahn/data/state.go | 30 ++++-- .../autobahn/data/state_recovery_test.go | 45 +++++---- .../internal/autobahn/data/state_test.go | 60 ++++++------ .../internal/autobahn/epoch/registry.go | 86 ++++++++++++----- .../internal/autobahn/epoch/registry_test.go | 68 +++++++++++--- .../internal/autobahn/epoch/testonly.go | 9 ++ .../autobahn/producer/mempool_test.go | 3 + .../internal/p2p/giga/avail_test.go | 2 +- .../internal/p2p/giga/consensus_test.go | 2 +- sei-tendermint/internal/p2p/giga/data_test.go | 4 +- .../internal/p2p/giga_router_common_test.go | 2 +- 19 files changed, 345 insertions(+), 256 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 23429c46f3..cd1c4d3080 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -57,21 +57,20 @@ type loadedState struct { } func newInner(ds *data.State, loaded *loadedState) (*inner, error) { - start := ds.Registry().LatestEpoch() - genesis, ok := ds.Registry().EpochByIndex(0) - if !ok { - return nil, fmt.Errorf("genesis epoch 0 not registered") + genesis, err := ds.Registry().EpochByIndex(0) + if err != nil { + return nil, fmt.Errorf("genesis epoch 0: %w", err) } i := &inner{ persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: genesis}), roads: newQueue[types.RoadIndex, *road](), - epoch: utils.NewAtomicSend(start), + epoch: utils.NewAtomicSend(genesis), blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, } - for lane := range start.Committee().Lanes().All() { + for lane := range genesis.Committee().Lanes().All() { i.addLane(lane) } for lane := range loaded.blocks { @@ -92,22 +91,21 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { if qc.Index() != i.roads.next { return nil, fmt.Errorf("non-contiguous persisted commitQCs: expected %d, got %d", i.roads.next, qc.Index()) } - epoch, ok := ds.Registry().EpochByIndex(qc.Proposal().EpochIndex()) - if !ok { - return nil, fmt.Errorf("epoch not found") + ep, err := ds.Registry().EpochByIndex(qc.Proposal().EpochIndex()) + if err != nil { + return nil, fmt.Errorf("persisted commitQC %d epoch: %w", qc.Index(), err) } - if err := qc.Verify(epoch); err != nil { + if err := qc.Verify(ep); err != nil { return nil, fmt.Errorf("persisted commitQC %d verify: %w", qc.Index(), err) } - i.roads.pushBack(newRoad(qc, epoch)) + i.roads.pushBack(newRoad(qc, ep)) } if i.roads.Len() > 0 { last := i.roads.q[i.roads.next-1] i.persistedCommitQC.Store(utils.Some(last.commitQC)) - // Floor applied at the durable tip's epoch. Bare Store on - // purpose: this is a rewind from LatestEpoch, not an advanceEpoch. - // The advance loop below re-drives it from the durable leashes. - i.epoch.Store(last.epoch) + i.seedApplied(last.epoch) + } else if ae, ok := i.anchorEpoch.Get(); ok { + i.seedApplied(ae) } // Restore persisted blocks. Since the anchor is persisted first and @@ -151,6 +149,15 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { return i, nil } +// seedApplied sets applied to ep and opens its lanes. Construction only; +// live advances go through advanceEpoch. +func (i *inner) seedApplied(ep *types.Epoch) { + for lane := range ep.Committee().Lanes().All() { + i.addLane(lane) + } + i.epoch.Store(ep) +} + // advanceEpoch makes ep the applied epoch: opens its lanes, reweights votes, // and republishes ConsensusSpec. func (i *inner) advanceEpoch(ep *types.Epoch) { @@ -185,9 +192,9 @@ func (i *inner) canAdvanceEpoch() bool { func (i *inner) advanceReadyEpochs(ds *data.State) error { for i.canAdvanceEpoch() { nextIdx := i.epoch.Load().EpochIndex() + 1 - next, ok := ds.Registry().EpochByIndex(nextIdx) - if !ok { - return fmt.Errorf("epoch %d not registered with seal+prune leashes met", nextIdx) + next, err := ds.Registry().EpochByIndex(nextIdx) + if err != nil { + return fmt.Errorf("epoch %d with seal+prune leashes met: %w", nextIdx, err) } i.advanceEpoch(next) } diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 60138703e6..e22e2d9b5f 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -46,7 +46,7 @@ func TestNewInner_Empty(t *testing.T) { _, ok := i.persistedCommitQC.Load().Get() require.False(t, ok) require.NotNil(t, i.nextBlockToPersist) - for lane := range registry.LatestEpoch().Committee().Lanes().All() { + for lane := range registry.MustEpoch(0).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) @@ -58,7 +58,7 @@ func TestNewInner_LoadedBlocks(t *testing.T) { t.Run("contiguous", func(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + lane0 := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("keys[0]") ds := newTestDataState(&data.Config{Registry: registry}) bs := contiguousBlocks(keys[0], lane0, 3, rng) i, err := newInner(ds, &loadedState{blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs}}) @@ -70,7 +70,7 @@ func TestNewInner_LoadedBlocks(t *testing.T) { require.Equal(t, b.Proposal, q.q[types.BlockNumber(j)]) } require.Equal(t, types.BlockNumber(3), i.nextBlockToPersist[lane0]) - for other := range registry.LatestEpoch().Committee().Lanes().All() { + for other := range registry.MustEpoch(0).Committee().Lanes().All() { if other != lane0 { require.Equal(t, types.BlockNumber(0), i.nextBlockToPersist[other]) } @@ -80,7 +80,7 @@ func TestNewInner_LoadedBlocks(t *testing.T) { t.Run("empty slice", func(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + lane0 := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("keys[0]") i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ blocks: map[types.LaneID][]persist.LoadedBlock{lane0: {}}, }) @@ -104,7 +104,7 @@ func TestNewInner_LoadedBlocks(t *testing.T) { require.Equal(t, types.BlockNumber(0), q.first) require.Equal(t, types.BlockNumber(1), q.next) require.Equal(t, b, q.q[0]) - for lane := range registry.LatestEpoch().Committee().Lanes().All() { + for lane := range registry.MustEpoch(0).Committee().Lanes().All() { cq := i.blocks[lane] require.Equal(t, types.BlockNumber(0), cq.first) require.Equal(t, types.BlockNumber(0), cq.next) @@ -114,8 +114,8 @@ func TestNewInner_LoadedBlocks(t *testing.T) { t.Run("multiple lanes", func(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") - lane1 := registry.LatestEpoch().Committee().Lane(keys[1].Public()).OrPanic("keys[1]") + lane0 := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + lane1 := registry.MustEpoch(0).Committee().Lane(keys[1].Public()).OrPanic("keys[1]") bs0 := contiguousBlocks(keys[0], lane0, 2, rng) bs1 := contiguousBlocks(keys[1], lane1, 3, rng) i, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ @@ -131,7 +131,7 @@ func TestNewInner_LoadedBlocks(t *testing.T) { t.Run("gap", func(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + lane0 := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("keys[0]") var bs []persist.LoadedBlock for _, n := range []types.BlockNumber{3, 4, 6, 7} { bs = append(bs, persist.LoadedBlock{Number: n, Proposal: testSignedBlock(keys[0], lane0, n, types.BlockHeaderHash{}, rng)}) @@ -146,7 +146,7 @@ func TestNewInner_LoadedBlocks(t *testing.T) { t.Run("parent hash mismatch", func(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + lane0 := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("keys[0]") var parent types.BlockHeaderHash b0 := testSignedBlock(keys[0], lane0, 0, parent, rng) parent = b0.Msg().Block().Header().Hash() @@ -164,7 +164,7 @@ func TestNewInner_LoadedBlocks(t *testing.T) { t.Run("over capacity", func(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - lane0 := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("keys[0]") + lane0 := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("keys[0]") bs := contiguousBlocks(keys[0], lane0, BlocksPerLane+5, rng) _, err := newInner(newTestDataState(&data.Config{Registry: registry}), &loadedState{ blocks: map[types.LaneID][]persist.LoadedBlock{lane0: bs}, @@ -182,7 +182,7 @@ func TestNewInner_LoadedCommitQCs(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = types.BuildCommitQC(registry.LatestEpoch(), keys, prev, nil) + qcs[i] = types.BuildCommitQC(registry.MustEpoch(0), keys, prev, nil) prev = utils.Some(qcs[i]) } inner, err := newInner(ds, &loadedState{commitQCs: qcs}) @@ -199,9 +199,9 @@ func TestNewInner_LoadedCommitQCs(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) ds := newTestDataState(&data.Config{Registry: registry}) - qc0 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.None[*types.CommitQC](), nil) - qc1 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.Some(qc0), nil) - qc2 := types.BuildCommitQC(registry.LatestEpoch(), keys, utils.Some(qc1), nil) + qc0 := types.BuildCommitQC(registry.MustEpoch(0), keys, utils.None[*types.CommitQC](), nil) + qc1 := types.BuildCommitQC(registry.MustEpoch(0), keys, utils.Some(qc0), nil) + qc2 := types.BuildCommitQC(registry.MustEpoch(0), keys, utils.Some(qc1), nil) _, err := newInner(ds, &loadedState{commitQCs: []*types.CommitQC{qc0, qc2}}) require.Error(t, err) require.Contains(t, err.Error(), "non-contiguous") @@ -234,8 +234,7 @@ func TestAdvanceReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 4) ds := newTestDataState(&data.Config{Registry: registry}) - ep0, ok := registry.EpochByIndex(0) - require.True(t, ok) + ep0 := registry.MustEpoch(0) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("data.Run", func() error { @@ -266,8 +265,7 @@ func TestAdvanceReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { require.Equal(t, types.EpochIndex(0), anchor.Epoch.EpochIndex()) registry.AdvanceIfNeeded(epoch.LastRoad(0)) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok) + ep1 := registry.MustEpoch(1) last := epoch.LastRoad(0) prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ @@ -311,8 +309,7 @@ func TestAdvanceReadyEpochs_MissingNextEpochErrors(t *testing.T) { rng := utils.TestRng() // Fresh registry has epochs 0 and 1; seal epoch 1 so the next lookup is 2. registry, keys := epoch.GenRegistry(rng, 3) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok) + ep1 := registry.MustEpoch(1) _, err := registry.EpochAt(epoch.FirstRoad(2)) require.Error(t, err) @@ -354,10 +351,8 @@ func TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied(t *testing.T registry, keys := epoch.GenRegistry(rng, 4) registry.AdvanceIfNeeded(epoch.LastRoad(0)) - ep0, ok := registry.EpochByIndex(0) - require.True(t, ok) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok) + ep0 := registry.MustEpoch(0) + ep1 := registry.MustEpoch(1) last := epoch.LastRoad(0) qcPrev := types.BuildCommitQC(ep0, keys, utils.Some(tipLink(ep0, keys[0], last-2)), nil) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 663b42d964..1d5fd143e1 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -704,6 +704,9 @@ func (s *State) runEvict(ctx context.Context) error { func (s *State) runEpochAdvance(ctx context.Context) error { for { next := s.epoch.Load().EpochIndex() + 1 + // ErrPruned is not expected here: PushCommitQC withholds a CommitQC + // until its epoch is applied, so the Anchor never leads applied by more + // than one epoch and PruneBefore cannot drop next. ep, err := s.data.Registry().WaitForEpoch(ctx, next) if err != nil { return err diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index b18a4fe358..6cf1c03ddf 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -18,7 +18,7 @@ import ( ) func pushPeerLaneBlock(state *State, key types.SecretKey, payload *types.Payload) (*types.Signed[*types.LaneProposal], error) { - lane := state.data.Registry().LatestEpoch().Committee().Lane(key.Public()).OrPanic("lane") + lane := state.data.Registry().MustEpoch(0).Committee().Lane(key.Public()).OrPanic("lane") var b *types.Signed[*types.LaneProposal] for inner, ctrl := range state.inner.Lock() { q, ok := inner.blocks[lane] @@ -101,7 +101,7 @@ func TestPrune_AnchorEpochDropsClosedLane(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) sc.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) - ep0 := registry.LatestEpoch() + ep0 := registry.MustEpoch(0) qc0, blocks0 := data.TestCommitQC(rng, ep0, keys, utils.None[*types.CommitQC]()) if err := ds.PushQC(ctx, qc0, blocks0); err != nil { return err @@ -130,6 +130,7 @@ func TestPrune_AnchorEpochDropsClosedLane(t *testing.T) { sub := state.SubscribeLaneProposals(lane0, 0) epLeave, err := registry.ActivateEpoch( + 0, map[types.PublicKey]uint64{b.Public(): 1}, time.Time{}, registry.FirstBlock(), ) @@ -148,10 +149,7 @@ func TestPrune_AnchorEpochDropsClosedLane(t *testing.T) { }); err != nil { return err } - ep1, ok := registry.EpochByIndex(1) - if !ok { - return fmt.Errorf("epoch 1 missing") - } + ep1 := registry.MustEpoch(1) for inner, ctrl := range state.inner.Lock() { inner.anchorEpoch = utils.Some(ep1) ctrl.Updated() @@ -207,7 +205,7 @@ func TestAnchorResetsState(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - epoch := registry.LatestEpoch() + epoch := registry.MustEpoch(0) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { t.Logf("data.Run()") ds := newTestDataState(&data.Config{Registry: registry}) @@ -247,7 +245,7 @@ func TestAnchorResetsState(t *testing.T) { s.SpawnBgNamed("avail.Run", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) t.Logf("push next CommitQC to avail") - qc, _ = data.TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc.QC())) + qc, _ = data.TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc.QC())) if err := state.PushCommitQC(ctx, qc.QC()); err != nil { return err } @@ -264,7 +262,7 @@ func testState(t *testing.T, rng utils.Rng, stateDir utils.Option[string]) { t.Helper() ctx := t.Context() registry, keys := epoch.GenRegistry(rng, 3) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { ds := newTestDataState(&data.Config{Registry: registry}) @@ -312,11 +310,11 @@ func testState(t *testing.T, rng utils.Rng, stateDir utils.Option[string]) { } t.Logf("Push a commit QC.") - laneQCs, err := state.WaitForLaneQCs(ctx, registry.LatestEpoch(), prev) + laneQCs, err := state.WaitForLaneQCs(ctx, registry.MustEpoch(0), prev) if err != nil { return fmt.Errorf("state.WaitForNewLaneQCs(): %w", err) } - qc := types.BuildCommitQC(registry.LatestEpoch(), keys, prev, laneQCs) + qc := types.BuildCommitQC(registry.MustEpoch(0), keys, prev, laneQCs) if err := state.PushCommitQC(ctx, qc); err != nil { return fmt.Errorf("state.PushCommitQC(): %w", err) } @@ -388,7 +386,7 @@ func testState(t *testing.T, rng utils.Rng, stateDir utils.Option[string]) { func TestStateRestartFromPersisted(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() // Phase 1: Run state with persistence through 2 iterations. @@ -438,11 +436,11 @@ func TestStateRestartFromPersisted(t *testing.T) { } } - laneQCs, err := state.WaitForLaneQCs(ctx, registry.LatestEpoch(), prev) + laneQCs, err := state.WaitForLaneQCs(ctx, registry.MustEpoch(0), prev) if err != nil { return fmt.Errorf("WaitForLaneQCs: %w", err) } - qc := types.BuildCommitQC(registry.LatestEpoch(), keys, prev, laneQCs) + qc := types.BuildCommitQC(registry.MustEpoch(0), keys, prev, laneQCs) if err := state.PushCommitQC(ctx, qc); err != nil { return fmt.Errorf("PushCommitQC: %w", err) } @@ -502,7 +500,7 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() lane := committee.Lane(keys[0].Public()).OrPanic("lane") // Produce a valid first block on our lane. _, err := state.ProduceLocalBlock(lane, state.NextBlock(lane), types.GenPayload(rng)) @@ -526,7 +524,7 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { ds := newTestDataState(&data.Config{Registry: registry}) state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) - lane := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("lane") + lane := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("lane") // Create a block on keys[0]'s lane but sign it with keys[1]. block := types.NewBlock(lane, 0, types.GenBlockHeaderHash(rng), types.GenPayload(rng)) prop := types.Sign(keys[1], types.NewLaneProposal(block)) @@ -552,7 +550,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted blocks", func(t *testing.T) { dir := t.TempDir() ds := newTestDataState(&data.Config{Registry: registry}) - lane := registry.LatestEpoch().Committee().Lane(keys[0].Public()).OrPanic("lane") + lane := registry.MustEpoch(0).Committee().Lane(keys[0].Public()).OrPanic("lane") // Persist blocks using BlockPersister. bp, _, err := persist.NewBlockPersister(utils.Some(dir)) @@ -591,7 +589,7 @@ func TestNewStateWithPersistence(t *testing.T) { qcs := make([]*types.CommitQC, 3) prev := utils.None[*types.CommitQC]() for i := range qcs { - qcs[i] = types.BuildCommitQC(registry.LatestEpoch(), keys, prev, nil) + qcs[i] = types.BuildCommitQC(registry.MustEpoch(0), keys, prev, nil) prev = utils.Some(qcs[i]) require.NoError(t, cp.PruneAndPersist(0, []*types.CommitQC{qcs[i]})) } @@ -615,7 +613,7 @@ func TestNewStateWithPersistence(t *testing.T) { allQCs := make([]*types.CommitQC, 6) prev := utils.None[*types.CommitQC]() for i := range allQCs { - allQCs[i] = types.BuildCommitQC(registry.LatestEpoch(), keys, prev, nil) + allQCs[i] = types.BuildCommitQC(registry.MustEpoch(0), keys, prev, nil) prev = utils.Some(allQCs[i]) } @@ -661,12 +659,13 @@ func TestHeaders_WaitsForPrevEpochLaneVote(t *testing.T) { return utils.IgnoreCancel(state.runEpochAdvance(ctx)) }) - ep0 := registry.LatestEpoch() + ep0 := registry.MustEpoch(0) lane := ep0.Committee().Lane(stay.Public()).OrPanic("stay lane") header := types.NewBlock(lane, 0, types.BlockHeaderHash{}, &types.Payload{}).Header() leaverVote := types.Sign(leaver, types.NewLaneVote(header)) epLeave, err := registry.ActivateEpoch( + 0, map[types.PublicKey]uint64{stay.Public(): 1, a.Public(): 1, b.Public(): 1}, time.Time{}, registry.FirstBlock(), ) @@ -738,8 +737,7 @@ func TestPushCommitQC_MidEpochNoWait(t *testing.T) { require.Error(t, err) seekRoads(f.state, epoch.FirstRoad(f.m)) - epPrev, ok := f.registry.EpochByIndex(f.m - 1) - require.True(t, ok) + epPrev := f.registry.MustEpoch(f.m - 1) prev := tipLink(epPrev, f.keys[0], epoch.LastRoad(f.m-1)) qc := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) require.Equal(t, epoch.FirstRoad(f.m), qc.Proposal().Index()) @@ -761,8 +759,7 @@ func TestPushCommitQC_FutureEpochParksUntilAdvance(t *testing.T) { setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) - ep2, ok := f.registry.EpochByIndex(f.m + 1) - require.True(t, ok) + ep2 := f.registry.MustEpoch(f.m + 1) qcNext := types.BuildCommitQC(ep2, f.keys, utils.Some(qcLast), nil) require.Equal(t, epoch.FirstRoad(f.m+1), qcNext.Proposal().Index()) @@ -794,8 +791,7 @@ func TestPushCommitQC_StaleAfterAdvanceSoftDrops(t *testing.T) { state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok) + ep1 := registry.MustEpoch(1) require.NoError(t, scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBgNamed("runEpochAdvance", func() error { return utils.IgnoreCancel(state.runEpochAdvance(ctx)) @@ -803,8 +799,7 @@ func TestPushCommitQC_StaleAfterAdvanceSoftDrops(t *testing.T) { return DriveAdvance(ctx, state, keys, ep1.EpochIndex()) })) - ep0, ok := registry.EpochByIndex(0) - require.True(t, ok) + ep0 := registry.MustEpoch(0) before := nextRoad(state) qc0 := types.BuildCommitQC(ep0, keys, utils.None[*types.CommitQC](), nil) require.Equal(t, types.EpochIndex(0), qc0.Proposal().EpochIndex()) @@ -829,8 +824,7 @@ func newSealFixture(t *testing.T) *sealFixture { require.NoError(t, err) const m types.EpochIndex = 1 - ep, ok := registry.EpochByIndex(m) - require.True(t, ok, "epoch 1 is present from NewRegistry") + ep := registry.MustEpoch(m) _, err = registry.EpochAt(epoch.FirstRoad(m + 1)) require.Error(t, err, "epoch 2 must be absent for exec-leash tests") @@ -853,8 +847,7 @@ func TestWaitUntilApplied_ParksUntilEpochAdvance(t *testing.T) { state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok, "epoch 1 is present from NewRegistry") + ep1 := registry.MustEpoch(1) require.NoError(t, scope.Run(ctx, func(ctx context.Context, sc scope.Scope) error { sc.SpawnBgNamed("runEpochAdvance", func() error { diff --git a/sei-tendermint/internal/autobahn/avail/subscriptions_test.go b/sei-tendermint/internal/autobahn/avail/subscriptions_test.go index f9afde2385..5e825483ed 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions_test.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions_test.go @@ -86,6 +86,7 @@ func TestSubscribeLaneProposals_StayLeaveRejoin(t *testing.T) { // Leave: peer drops from committee at epoch 2 (first vacant after genesis seeds). // Anchor-epoch prune drops closed lane maps (same path as runEvict) and ends the subscribe. epLeave, err := registry.ActivateEpoch( + 0, map[types.PublicKey]uint64{b.Public(): 1}, time.Time{}, registry.FirstBlock(), ) @@ -131,6 +132,7 @@ func TestSubscribeLaneProposals_StayLeaveRejoin(t *testing.T) { return nil }) epJoin, err := registry.ActivateEpoch( + epLeave.EpochIndex(), map[types.PublicKey]uint64{a.Public(): 1, b.Public(): 1}, time.Time{}, registry.FirstBlock(), ) @@ -176,8 +178,8 @@ func TestJoinerCatchup_LaneVotes(t *testing.T) { stateB := utils.OrPanic1(NewState(b, ds, utils.None[string]())) laneA := stateA.LocalLane().OrPanic("genesis") - activate := func(weights map[types.PublicKey]uint64) *types.Epoch { - ep, err := registry.ActivateEpoch(weights, time.Time{}, registry.FirstBlock()) + activate := func(parent types.EpochIndex, weights map[types.PublicKey]uint64) *types.Epoch { + ep, err := registry.ActivateEpoch(parent, weights, time.Time{}, registry.FirstBlock()) require.NoError(t, err) return ep } @@ -200,7 +202,7 @@ func TestJoinerCatchup_LaneVotes(t *testing.T) { onlyA := map[types.PublicKey]uint64{a.Public(): 1} block0 := produce(0) - epJoin := activate(both) + epJoin := activate(0, both) advance(epJoin.EpochIndex()) require.Equal(t, types.EpochIndex(2), stateB.LocalLane().OrPanic("joiner").Joined) @@ -211,11 +213,11 @@ func TestJoinerCatchup_LaneVotes(t *testing.T) { require.Equal(t, block0.Msg().Block().Header().Hash(), batch[0].Msg().Header().Hash()) require.Equal(t, b.Public(), batch[0].Key()) - epLeave := activate(onlyA) + epLeave := activate(epJoin.EpochIndex(), onlyA) advance(epLeave.EpochIndex()) block1 := produce(1) // while out; skip RecvBatch so the cursor stays behind block1 - epRejoin := activate(both) + epRejoin := activate(epLeave.EpochIndex(), both) advance(epRejoin.EpochIndex()) require.Equal(t, types.EpochIndex(4), stateB.LocalLane().OrPanic("rejoiner").Joined) diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index e6a0c6f81a..57d2a869f9 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -152,10 +152,8 @@ func TestNewInner_RejectsWALAheadOfSpec(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 4) registry.AdvanceIfNeeded(epoch.LastRoad(0)) - ep0, ok := registry.EpochByIndex(0) - require.True(t, ok) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok) + ep0 := registry.MustEpoch(0) + ep1 := registry.MustEpoch(1) last := epoch.LastRoad(0) prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ @@ -183,10 +181,8 @@ func TestNewInner_EqualTipKeepsVotes(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 4) registry.AdvanceIfNeeded(epoch.LastRoad(0)) - ep0, ok := registry.EpochByIndex(0) - require.True(t, ok) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok) + ep0 := registry.MustEpoch(0) + ep1 := registry.MustEpoch(1) last := epoch.LastRoad(0) prev := types.NewCommitQC([]*types.Signed[*types.CommitVote]{ @@ -280,7 +276,7 @@ func TestNewInnerPrepareVote(t *testing.T) { // Create and persist a prepare vote at genesis view (0, 0) 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, registry.MustEpoch(0), types.View{Index: 0, Number: 0}) vote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ @@ -302,7 +298,7 @@ func TestNewInnerCommitVote(t *testing.T) { // Create and persist a commit vote at genesis view (0, 0) 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, registry.MustEpoch(0), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC([]types.SecretKey{key}, genesisProposal) vote := types.Sign(key, types.NewCommitVote(genesisProposal)) @@ -347,7 +343,7 @@ func TestNewInnerAllVotes(t *testing.T) { // Create all vote types at genesis view (0, 0) 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, registry.MustEpoch(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)) @@ -375,7 +371,7 @@ func TestNewInnerPartialState(t *testing.T) { // Only persist prepareVote 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, registry.MustEpoch(0), types.View{Index: 0, Number: 0}) prepareVote := types.Sign(key, types.NewPrepareVote(genesisProposal)) seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ @@ -396,7 +392,7 @@ func TestNewInnerCommitQC(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) vote := types.NewCommitVote(proposal) var votes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -423,7 +419,7 @@ func TestNewInnerTimeoutQC(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -499,7 +495,7 @@ func TestNewInnerTimeoutQCAheadOfCommitQCError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -528,7 +524,7 @@ func TestNewInnerViewSpecStaleTimeoutQC(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 10, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -558,7 +554,7 @@ func TestNewInnerViewSpecValidBothQCs(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -590,7 +586,7 @@ func TestNewInnerStaleVoteError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -600,7 +596,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, registry.MustEpoch(0), types.View{Index: 3, Number: 0}) staleVote := types.Sign(keys[0], types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(staleVote)}) @@ -616,7 +612,7 @@ func TestNewInnerFuturePrepareVoteError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -625,7 +621,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, registry.MustEpoch(0), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewPrepareVote(futureProposal)) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(futureVote)}) @@ -642,7 +638,7 @@ func TestNewInnerFutureCommitVoteError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -651,7 +647,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, registry.MustEpoch(0), types.View{Index: 10, Number: 0}) futureVote := types.Sign(keys[0], types.NewCommitVote(futureProposal)) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{CommitVote: utils.Some(futureVote)}) @@ -668,7 +664,7 @@ func TestNewInnerFutureTimeoutVoteError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -693,7 +689,7 @@ func TestNewInnerCurrentViewVoteOk(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -702,7 +698,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, registry.MustEpoch(0), types.View{Index: 6, Number: 0}) currentVote := types.Sign(keys[0], types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(currentVote)}) @@ -719,7 +715,7 @@ func TestNewInnerTimeoutQCInvalidSignatureError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -752,7 +748,7 @@ func TestNewInnerCurrentViewVoteInvalidSignatureError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -762,7 +758,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, registry.MustEpoch(0), types.View{Index: 6, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(currentProposal)) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(badVote)}) @@ -779,7 +775,7 @@ func TestNewInnerStaleVoteInvalidSignatureError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -790,7 +786,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, registry.MustEpoch(0), types.View{Index: 3, Number: 0}) badVote := types.Sign(otherKey, types.NewPrepareVote(staleProposal)) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareVote: utils.Some(badVote)}) @@ -806,7 +802,7 @@ func TestNewInnerPrepareQC(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 0, Number: 0}) prepareQC := makePrepareQC(keys, proposal) seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ @@ -825,7 +821,7 @@ func TestNewInnerStalePrepareQCError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -835,7 +831,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, registry.MustEpoch(0), types.View{Index: 3, Number: 0}) stalePrepareQC := makePrepareQC(keys, staleProposal) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(stalePrepareQC)}) @@ -852,7 +848,7 @@ func TestNewInnerCommitVoteWithoutPrepareQCError(t *testing.T) { // 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, registry.MustEpoch(0), types.View{Index: 0, Number: 0}) commitVote := types.Sign(keys[0], types.NewCommitVote(proposal)) seedPersistedInner(dir, utils.None[*types.CommitQC](), &persistedInner{ @@ -870,7 +866,7 @@ func TestNewInnerFuturePrepareQCError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -879,7 +875,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, registry.MustEpoch(0), types.View{Index: 10, Number: 0}) prepareQC := makePrepareQC(keys, futureProposal) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(prepareQC)}) @@ -896,7 +892,7 @@ func TestNewInnerCurrentViewPrepareQCOk(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -905,7 +901,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, registry.MustEpoch(0), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(prepareQC)}) @@ -922,7 +918,7 @@ func TestNewInnerCurrentViewPrepareQCInvalidSignatureError(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -935,7 +931,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, registry.MustEpoch(0), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(otherKeys, currentProposal) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(prepareQC)}) @@ -953,7 +949,7 @@ func TestNewInnerPrepareQCIncludedInTimeoutVote(t *testing.T) { 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, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -962,7 +958,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, registry.MustEpoch(0), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) seedPersistedInner(dir, utils.Some(commitQC), &persistedInner{PrepareQC: utils.Some(prepareQC)}) @@ -977,7 +973,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(registry.MustEpoch(0)) require.NoError(t, err, "timeoutVote with loaded prepareQC should verify") // Verify the loaded prepareQC matches what we persisted @@ -994,7 +990,7 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { 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}) + qcProposal := types.GenProposalForEpoch(rng, registry.MustEpoch(0), types.View{Index: 5, Number: 0}) qcVote := types.NewCommitVote(qcProposal) var qcVotes []*types.Signed[*types.CommitVote] for _, k := range keys { @@ -1003,7 +999,7 @@ func TestPushTimeoutQCClearsStaleState(t *testing.T) { commitQC := types.NewCommitQC(qcVotes) // Setup: Create prepareQC at current view (6, 0) - currentProposal := types.GenProposalForEpoch(rng, registry.LatestEpoch(), types.View{Index: 6, Number: 0}) + currentProposal := types.GenProposalForEpoch(rng, registry.MustEpoch(0), types.View{Index: 6, Number: 0}) prepareQC := makePrepareQC(keys, currentProposal) // Setup: Create votes at current view (6, 0) @@ -1111,8 +1107,7 @@ func TestPushCommitQC_RotatesEpochAtBoundary(t *testing.T) { s := newConsensusState(t, registry, keys[0]) require.Equal(t, types.EpochIndex(0), s.innerRecv.Load().spec.Epoch.EpochIndex()) - ep0, ok := registry.EpochByIndex(0) - require.True(t, ok) + ep0 := registry.MustEpoch(0) qc := commitQCAtRoad(ep0, keys, epoch.LastRoad(0)) require.Equal(t, epoch.LastRoad(0), qc.Proposal().Index()) @@ -1130,8 +1125,7 @@ func TestNewState_ErrAvailBehindConsensus(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 4) dir := t.TempDir() - ep0, ok := registry.EpochByIndex(0) - require.True(t, ok) + ep0 := registry.MustEpoch(0) qc := commitQCAtRoad(ep0, keys, 3) seedPersistedInner(dir, utils.Some(qc), &persistedInner{}) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index f9e867ae0c..bdde356f20 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -62,7 +62,7 @@ func TestNewCommitQCPersisterEmptyDir(t *testing.T) { func TestPersistCommitQCAndLoad(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 3) @@ -89,7 +89,7 @@ func TestPersistCommitQCAndLoad(t *testing.T) { func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) @@ -111,7 +111,7 @@ func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { func TestCommitQCDeleteBeforeZero(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 3) @@ -136,7 +136,7 @@ func TestCommitQCDeleteBeforeZero(t *testing.T) { func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 3) @@ -154,7 +154,7 @@ func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { func TestCommitQCPersistGapRejected(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) @@ -176,7 +176,7 @@ func TestCommitQCPersistGapRejected(t *testing.T) { func TestLoadAllDropsCommitQCsBehindGap(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() // Build 3 sequential CommitQCs (indices 0, 1, 2). @@ -201,7 +201,7 @@ func TestLoadAllDropsCommitQCsBehindGap(t *testing.T) { func TestNoOpCommitQCPersister(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() qcs := makeSequentialCommitQCs(committee, keys, 11) // Fresh no-op persister: persist sequential QCs and track Next. @@ -222,7 +222,7 @@ func TestNoOpCommitQCPersister(t *testing.T) { func TestCommitQCDeleteBeforePastAll(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 12) @@ -253,7 +253,7 @@ func TestCommitQCDeleteBeforePastAll(t *testing.T) { func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 12) @@ -291,7 +291,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) @@ -320,7 +320,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 6) @@ -345,7 +345,7 @@ func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) @@ -373,7 +373,7 @@ func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { func TestCommitQCProgressiveDeleteBefore(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 8) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index cce77a55c7..dfb619f141 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -83,7 +83,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, registry.MustEpoch(0), types.View{Index: 0, Number: 0})) if err := s.pushPrepareQC(ctx, pqc); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -113,7 +113,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, registry.MustEpoch(0), view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -170,7 +170,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, registry.MustEpoch(0), view0)) if err := s.pushPrepareQC(ctx, pqc0); err != nil { return fmt.Errorf("pushPrepareQC(pqc0): %w", err) } @@ -183,7 +183,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, registry.MustEpoch(0), view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC(pqc1): %w", err) } @@ -227,7 +227,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, registry.MustEpoch(0), view1)) if err := s.pushPrepareQC(ctx, pqc1); err != nil { return fmt.Errorf("pushPrepareQC: %w", err) } @@ -269,7 +269,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, registry.MustEpoch(0), view0)) // Session 1: push PrepareQC + TimeoutQC, let runOutputs persist. // Hoisted so session 1's WALs can be released after its goroutines have stopped: they hold an diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 92d27678d7..c803998589 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -98,9 +98,9 @@ func (i *inner) insertQC(registry *epoch.Registry, qc *types.FullCommitQC) error if gr.First > i.nextQC { return fmt.Errorf("QC gap: expected first<=%d, got %d", i.nextQC, gr.First) } - e, ok := registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) + e, err := registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) + if err != nil { + return err } if err := qc.Verify(e); err != nil { return fmt.Errorf("qc.Verify(): %w", err) @@ -265,9 +265,9 @@ func loadFromBlockDB(cfg *Config, blockDB types.BlockDB) (*inner, error) { NextBlock: firstBlock, }) // Empty-chain default; loaded QCs overwrite via admitCommitRoad. - genesis, ok := cfg.Registry.EpochByIndex(0) - if !ok { - return nil, fmt.Errorf("missing genesis epoch") + genesis, err := cfg.Registry.EpochByIndex(0) + if err != nil { + return nil, fmt.Errorf("missing genesis epoch: %w", err) } inner := &inner{ qcs: map[types.GlobalBlockNumber]qcEntry{}, @@ -345,10 +345,14 @@ func (s *State) insertBlocksByHash(inner *inner, gr types.GlobalRange, byHash ma // PushQC pushes FullCommitQC and a subset of blocks that were finalized by it. // Pushing the qc and blocks is atomic, so that no unnecessary GetBlock RPCs are issued. // Even if the qc was already pushed earlier, the blocks are pushed anyway. +// A QC whose epoch has been pruned is a no-op. func (s *State) PushQC(ctx context.Context, qc *types.FullCommitQC, blocks []*types.Block) error { - ep, ok := s.cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) - if !ok { - return fmt.Errorf("unknown epoch_index %d", qc.QC().Proposal().EpochIndex()) + ep, err := s.cfg.Registry.EpochByIndex(qc.QC().Proposal().EpochIndex()) + if err != nil { + if errors.Is(err, types.ErrPruned) { + return nil + } + return err } gr := qc.QC().GlobalRange() needQC, err := func() (bool, error) { @@ -907,6 +911,7 @@ func (s *State) runPersist(ctx context.Context) error { for inner, ctrl := range s.inner.Lock() { inner.persisted = status t := time.Now() + from := inner.first for inner.first < inner.persisted.First { // Divergence detection n := inner.first @@ -926,6 +931,13 @@ func (s *State) runPersist(ctx context.Context) error { } s.metrics.NextBlock.Evict.Set(utils.Clamp[int64](inner.first)) inner.setAnchor() + if from < inner.first { + a, ok := inner.anchor.Load().Get() + if !ok { + return fmt.Errorf("evict advanced first but Anchor is None") + } + s.cfg.Registry.PruneBefore(a.Epoch.EpochIndex()) + } ctrl.Updated() } } diff --git a/sei-tendermint/internal/autobahn/data/state_recovery_test.go b/sei-tendermint/internal/autobahn/data/state_recovery_test.go index f78432cdee..47734ab6bf 100644 --- a/sei-tendermint/internal/autobahn/data/state_recovery_test.go +++ b/sei-tendermint/internal/autobahn/data/state_recovery_test.go @@ -36,7 +36,7 @@ func TestNewStateInMemoryMode(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) state := utils.OrPanic1(NewState(&Config{Registry: registry}, memblock.NewBlockDB())) @@ -63,8 +63,8 @@ func TestRecoveryNormal(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -101,7 +101,7 @@ func TestRecoveryNormal(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]()) + qc, _ := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) gr := qc.QC().GlobalRange() db := newTestBlockDB(t, t.TempDir()) @@ -116,8 +116,8 @@ func TestRecoveryStartsAtRegistryFloorWhenBlockDBMissingFirstCommittedBlock(t *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())) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) db := newTestBlockDB(t, t.TempDir()) writeToBlockDB(t, db, @@ -140,9 +140,9 @@ func TestPruningDiscards(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - qc3, blocks3 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc2.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) + qc3, blocks3 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc2.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() gr3 := qc3.QC().GlobalRange() @@ -177,9 +177,9 @@ func TestRecoveryAfterPruning(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) - qc3, blocks3 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc2.QC())) + qc1, _ := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) + qc3, blocks3 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc2.QC())) gr2 := qc2.QC().GlobalRange() gr3 := qc3.QC().GlobalRange() @@ -224,8 +224,8 @@ func TestRecoveryBlocksBehind(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -272,8 +272,8 @@ func TestRecoveryAfterPruneNoGC(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -316,7 +316,7 @@ func TestRecoveryQCsNoBlocks(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, _ := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() db1 := newTestBlockDB(t, dir) @@ -348,8 +348,8 @@ func TestRunPersistSeedsFromRecoveryFloor(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, _ := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, _ := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() require.Greater(t, gr2.First, registry.FirstBlock(), "need skipTo past genesis") @@ -398,7 +398,7 @@ func TestRecoveryBlockGap(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() // TestCommitQC generates 10 global blocks, so the range is always wide @@ -434,7 +434,7 @@ func TestRecoveryBlockGap(t *testing.T) { func TestNewState_SetupInitialEpochsFromCommitQCSpan(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) db := memblock.NewBlockDB() t.Cleanup(func() { require.NoError(t, db.Close()) }) @@ -461,8 +461,7 @@ func TestNewState_SetupInitialEpochsFromCommitQCSpan(t *testing.T) { func TestNewState_NextCommitEpochAtBoundaryTip(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok) + ep1 := registry.MustEpoch(1) qc, blocks := commitQCAtRoad(ep1, keys, epoch.LastRoad(1), ep1.FirstBlock()) db := newTestBlockDB(t, t.TempDir()) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 0def0d91ee..0c722b805c 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -133,8 +133,7 @@ func TestNextCommitEpoch_TracksNextRoadEpoch(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - ep0, ok := registry.EpochByIndex(0) - require.True(t, ok) + ep0 := registry.MustEpoch(0) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) require.Equal(t, ep0, state.NextCommitEpoch().Load()) @@ -155,8 +154,7 @@ func TestNextCommitEpoch_AdvancesAtIdleEpochBoundary(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - ep1, ok := registry.EpochByIndex(1) - require.True(t, ok) + ep1 := registry.MustEpoch(1) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) qcMid, blocksMid := commitQCAtRoad(ep1, keys, epoch.FirstRoad(1), ep1.FirstBlock()) @@ -193,7 +191,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.MustEpoch(0), keys, prev) prev = utils.Some(qc.QC()) if err := state.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("state.PushQC(): %w", err) @@ -254,11 +252,11 @@ func TestPushConflictingBadCommitQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).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.MustEpoch(0), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, blocks1)) gr1 := qc1.QC().GlobalRange() @@ -298,7 +296,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](), Epoch: registry.MustEpoch(0)} leader := committee.Leader(viewSpec.View()) var leaderKey types.SecretKey for _, k := range keys { @@ -347,7 +345,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.MustEpoch(0), 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++ { @@ -364,7 +362,7 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push qc1 with NO blocks — only the QC is stored. - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc1, nil)) gr := qc1.QC().GlobalRange() @@ -413,7 +411,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.MustEpoch(0), keys, prev) if err := state.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("state.PushQC(): %w", err) } @@ -447,7 +445,7 @@ func TestPushAppHashRejectsJumpOverCommitQCRange(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) - epoch := registry.LatestEpoch() + epoch := registry.MustEpoch(0) var qcs []*types.CommitQC for range 3 { var prev utils.Option[*types.CommitQC] @@ -499,7 +497,7 @@ func TestPushAppHash_AdvancesRegistryAtEpochBoundary(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) if err := state.PushQC(ctx, qc, blocks); err != nil { return err } @@ -516,7 +514,7 @@ func TestPushAppHash_AdvancesRegistryAtEpochBoundary(t *testing.T) { t.Run("LastRoad does not seed epoch 2", func(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) - ep := registry.LatestEpoch() + ep := registry.MustEpoch(0) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) qc, blocks := commitQCAtRoad(ep, keys, epoch.LastRoad(0), ep.FirstBlock()) @@ -545,7 +543,7 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push QC without blocks. - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, nil)) gr := qc.QC().GlobalRange() @@ -563,7 +561,7 @@ func TestGlobalBlockByHash(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) - qc, blocks := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc, blocks := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, blocks)) gr := qc.QC().GlobalRange() n := gr.First @@ -603,7 +601,7 @@ func TestPushQCBeforeRunPersistsToBlockDB(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() db := newTestBlockDB(t, dir) @@ -647,9 +645,9 @@ func TestEvictionWaitsForAppQC(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), 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.MustEpoch(0), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -738,7 +736,7 @@ func TestEvictionWaitsForPersistedAppQC(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -761,7 +759,7 @@ func TestPushAppHashBelowAnchorSucceeds(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - epoch := registry.LatestEpoch() + epoch := registry.MustEpoch(0) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -808,9 +806,9 @@ func TestNextToExecuteAfterAppEviction(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), 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.MustEpoch(0), keys, utils.Some(qc1.QC())) state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { @@ -892,7 +890,7 @@ func TestPushAppQCPersistsAndRecovers(t *testing.T) { registry, keys := epoch.GenRegistry(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() db1 := newTestBlockDB(t, dir) @@ -953,7 +951,7 @@ func TestPruningKeepsLastQCRange(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange() state1 := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) @@ -997,8 +995,8 @@ func TestPruningWithPartialQCRange(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() @@ -1087,11 +1085,11 @@ func TestPushBlockWaitsForQC(t *testing.T) { state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) // Push first QC covering [0, N). - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), 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.MustEpoch(0), keys, utils.Some(qc1.QC())) gr2 := qc2.QC().GlobalRange() // Block gr2.First should not be in state yet. @@ -1134,8 +1132,8 @@ func TestTryBlockHidesGapFills(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 3) - qc1, blocks1 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, registry.LatestEpoch(), keys, utils.Some(qc1.QC())) + qc1, blocks1 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange() gr2 := qc2.QC().GlobalRange() require.GreaterOrEqual(t, gr2.Len(), 2) diff --git a/sei-tendermint/internal/autobahn/epoch/registry.go b/sei-tendermint/internal/autobahn/epoch/registry.go index 2b05b6a161..d47aeacdfe 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry.go +++ b/sei-tendermint/internal/autobahn/epoch/registry.go @@ -29,8 +29,10 @@ func LastRoad(idx types.EpochIndex) types.RoadIndex { } type registryState struct { - m map[types.EpochIndex]*types.Epoch - latest types.EpochIndex + m map[types.EpochIndex]*types.Epoch + // prunedTo is the exclusive floor of dropped indices. Epochs in (0, prunedTo) + // are gone; 0 is always retained for genesis metadata and placeholders. + prunedTo types.EpochIndex } // Registry stores activated epochs and placeholders. @@ -48,8 +50,7 @@ func NewRegistry( ep1 := types.NewEpoch(1, types.RoadRange{First: FirstRoad(1), Next: FirstRoad(2)}, genesisTimestamp, committee, firstBlock) return &Registry{ state: utils.NewWatch(®istryState{ - m: map[types.EpochIndex]*types.Epoch{0: ep0, 1: ep1}, - latest: 0, + m: map[types.EpochIndex]*types.Epoch{0: ep0, 1: ep1}, }), }, nil } @@ -92,11 +93,18 @@ func (r *Registry) GenesisTimestamp() time.Time { panic("unreachable") } -// EpochByIndex returns the registered epoch at idx, if any. -func (r *Registry) EpochByIndex(idx types.EpochIndex) (*types.Epoch, bool) { +// EpochByIndex returns the registered epoch at idx. +// It returns ErrPruned if idx has been dropped by PruneBefore. +func (r *Registry) EpochByIndex(idx types.EpochIndex) (*types.Epoch, error) { for s := range r.state.Lock() { + if r.pruned(s, idx) { + return nil, fmt.Errorf("epoch %d: %w", idx, types.ErrPruned) + } ep, ok := s.m[idx] - return ep, ok + if !ok { + return nil, fmt.Errorf("epoch %d not registered", idx) + } + return ep, nil } panic("unreachable") } @@ -105,6 +113,9 @@ func (r *Registry) EpochByIndex(idx types.EpochIndex) (*types.Epoch, bool) { func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { epochIdx := IndexForRoad(roadIndex) for s := range r.state.Lock() { + if r.pruned(s, epochIdx) { + return nil, fmt.Errorf("epoch %d (road %d): %w", epochIdx, roadIndex, types.ErrPruned) + } if ep, ok := s.m[epochIdx]; ok { return ep, nil } @@ -113,32 +124,34 @@ func (r *Registry) EpochAt(roadIndex types.RoadIndex) (*types.Epoch, error) { panic("unreachable") } -// LatestEpoch returns the ActivateEpoch tip. -func (r *Registry) LatestEpoch() *types.Epoch { - for s := range r.state.Lock() { - return s.m[s.latest] - } - panic("unreachable") -} - -// ActivateEpoch registers the next vacant epoch after LatestEpoch with the given -// committee weights. Already-registered epochs are never modified. The first -// activation lands at index ≥ 2 (epochs 0 and 1 are always present). The new -// epoch's road range is FirstRoad(index)..FirstRoad(index+1). +// ActivateEpoch registers the next vacant epoch after parent. parent is the +// epoch at the execution tip; the new committee is derived from it. Already- +// registered epochs are never modified. Pruned indices are skipped. func (r *Registry) ActivateEpoch( + parent types.EpochIndex, weights map[types.PublicKey]uint64, firstTimestamp time.Time, firstBlock types.GlobalBlockNumber, ) (*types.Epoch, error) { for s, ctrl := range r.state.Lock() { - next := s.latest + 1 + if r.pruned(s, parent) { + return nil, fmt.Errorf("epoch %d: %w", parent, types.ErrPruned) + } + prev, ok := s.m[parent] + if !ok { + return nil, fmt.Errorf("epoch %d not registered", parent) + } + next := parent + 1 for { + if r.pruned(s, next) { + next++ + continue + } if _, ok := s.m[next]; !ok { break } next++ } - prev := s.m[s.latest] committee, err := prev.Committee().DeriveNext(weights, next) if err != nil { return nil, err @@ -146,7 +159,6 @@ func (r *Registry) ActivateEpoch( roads := types.RoadRange{First: FirstRoad(next), Next: FirstRoad(next + 1)} ep := types.NewEpoch(next, roads, firstTimestamp, committee, firstBlock) s.m[next] = ep - s.latest = next ctrl.Updated() return ep, nil } @@ -154,8 +166,7 @@ func (r *Registry) ActivateEpoch( } // makeEpoch inserts a genesis-committee placeholder at epochIdx. -// Caller must hold r.state. Epochs 0 and 1 are always present (seeded at -// construction with the genesis committee); further epochs copy from epoch 0. +// Caller must hold r.state. Epoch 0 is always present; further epochs copy from it. func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types.Epoch { ep0 := s.m[0] firstRoad := FirstRoad(epochIdx) @@ -171,8 +182,11 @@ func (r *Registry) makeEpoch(s *registryState, epochIdx types.EpochIndex) *types } // ensureLocked registers a genesis-committee placeholder for idx if missing. -// Caller must hold r.state. +// Caller must hold r.state. Pruned indices are not recreated. func (r *Registry) ensureLocked(s *registryState, idx types.EpochIndex) { + if r.pruned(s, idx) { + return + } if _, ok := s.m[idx]; !ok { r.makeEpoch(s, idx) } @@ -203,10 +217,32 @@ func (r *Registry) AdvanceIfNeeded(roadIndex types.RoadIndex) { } } +func (r *Registry) pruned(s *registryState, idx types.EpochIndex) bool { + return idx > 0 && idx < s.prunedTo +} + +// PruneBefore drops registered epochs in (0, keep). Epoch 0 is kept for +// genesis metadata. keep is an exclusive floor and only moves forward. +func (r *Registry) PruneBefore(keep types.EpochIndex) { + for s, ctrl := range r.state.Lock() { + if keep <= s.prunedTo { + return + } + for idx := max(s.prunedTo, 1); idx < keep; idx++ { + delete(s.m, idx) + } + s.prunedTo = keep + ctrl.Updated() + } +} + // WaitForEpoch blocks until epoch i is registered. func (r *Registry) WaitForEpoch(ctx context.Context, i types.EpochIndex) (*types.Epoch, error) { for inner, ctrl := range r.state.Lock() { for { + if r.pruned(inner, i) { + return nil, fmt.Errorf("epoch %d: %w", i, types.ErrPruned) + } if ep, ok := inner.m[i]; ok { return ep, nil } diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 0a3d9e5e1d..8544af5f9b 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -1,6 +1,7 @@ package epoch import ( + "errors" "testing" "testing/synctest" "time" @@ -28,16 +29,20 @@ func midRoad(idx types.EpochIndex) types.RoadIndex { func TestRegistry_EpochByIndex_UnknownReturnsNotFound(t *testing.T) { r, _ := makeRegistry(t) - if _, ok := r.EpochByIndex(99); ok { - t.Fatal("EpochByIndex(99) returned ok, want not found") + _, err := r.EpochByIndex(99) + if err == nil { + t.Fatal("EpochByIndex(99) succeeded, want not found") + } + if errors.Is(err, types.ErrPruned) { + t.Fatal("EpochByIndex(99) returned ErrPruned, want not registered") } } func TestRegistry_EpochByIndex_GenesisFound(t *testing.T) { r, _ := makeRegistry(t) - ep, ok := r.EpochByIndex(0) - if !ok { - t.Fatal("EpochByIndex(0) not found") + ep, err := r.EpochByIndex(0) + if err != nil { + t.Fatal(err) } if ep.EpochIndex() != 0 { t.Fatalf("EpochIndex() = %d, want 0", ep.EpochIndex()) @@ -168,26 +173,23 @@ func TestSetupInitialEpochs_CommitSpanFromFirst(t *testing.T) { func TestActivateEpoch_SkipsExistingSeeds(t *testing.T) { r, committee := makeRegistry(t) r.SetupInitialEpochs(utils.None[types.RoadRange]()) - require.Equal(t, types.EpochIndex(0), r.LatestEpoch().EpochIndex()) - seeded, ok := r.EpochByIndex(1) - require.True(t, ok) + seeded := r.MustEpoch(1) seededCommittee := seeded.Committee() pk := committee.Lanes().At(0).Validator ep, err := r.ActivateEpoch( + 0, map[types.PublicKey]uint64{pk: 1}, time.Time{}, r.FirstBlock(), ) require.NoError(t, err) require.Equal(t, types.EpochIndex(2), ep.EpochIndex()) - require.Equal(t, types.EpochIndex(2), r.LatestEpoch().EpochIndex()) require.Equal(t, FirstRoad(2), ep.RoadRange().First) require.Equal(t, FirstRoad(3), ep.RoadRange().Next) - got, ok := r.EpochByIndex(1) - require.True(t, ok) + got := r.MustEpoch(1) require.Equal(t, seededCommittee, got.Committee()) - _, ok = ep.Committee().Lane(pk).Get() + _, ok := ep.Committee().Lane(pk).Get() require.True(t, ok) require.Equal(t, 1, ep.Committee().Lanes().Len()) } @@ -203,6 +205,7 @@ func TestActivateEpoch_RejoinJoinedFromLatestNotPlaceholder(t *testing.T) { r.SetupInitialEpochs(utils.None[types.RoadRange]()) epLeave, err := r.ActivateEpoch( + 0, map[types.PublicKey]uint64{b.Public(): 1}, time.Time{}, r.FirstBlock(), ) @@ -210,14 +213,14 @@ func TestActivateEpoch_RejoinJoinedFromLatestNotPlaceholder(t *testing.T) { require.Equal(t, types.EpochIndex(2), epLeave.EpochIndex()) require.False(t, epLeave.Committee().HasReplica(a.Public())) - // Seed a genesis-committee placeholder ahead of latest. Deriving from that + // Seed a genesis-committee placeholder ahead of the activated epoch. Deriving from that // slot would treat A as still present and keep Joined=0. r.AdvanceIfNeeded(LastRoad(2)) - seeded, ok := r.EpochByIndex(3) - require.True(t, ok) + seeded := r.MustEpoch(3) require.True(t, seeded.Committee().HasReplica(a.Public())) epJoin, err := r.ActivateEpoch( + epLeave.EpochIndex(), map[types.PublicKey]uint64{a.Public(): 1, b.Public(): 1}, time.Time{}, r.FirstBlock(), ) @@ -255,3 +258,38 @@ func TestWaitForEpoch_FastPathAndWait(t *testing.T) { require.Equal(t, types.EpochIndex(2), got.EpochIndex()) }) } + +func TestPruneBefore_DropsIntermediateKeepsGenesis(t *testing.T) { + r, _ := makeRegistry(t) + r.AdvanceIfNeeded(LastRoad(0)) + r.AdvanceIfNeeded(LastRoad(1)) + _ = r.MustEpoch(1) + _ = r.MustEpoch(2) + + r.PruneBefore(2) + _ = r.MustEpoch(0) + _, err := r.EpochByIndex(1) + require.ErrorIs(t, err, types.ErrPruned) + _ = r.MustEpoch(2) + require.Equal(t, types.GlobalBlockNumber(0), r.FirstBlock()) + + _, err = r.EpochAt(FirstRoad(1)) + require.ErrorIs(t, err, types.ErrPruned) + _, err = r.WaitForEpoch(t.Context(), 1) + require.ErrorIs(t, err, types.ErrPruned) + + r.PruneBefore(1) // no rewind + _ = r.MustEpoch(2) + + pk := r.MustEpoch(0).Committee().Lanes().At(0).Validator + ep, err := r.ActivateEpoch( + 0, + map[types.PublicKey]uint64{pk: 1}, + time.Time{}, + r.FirstBlock(), + ) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(3), ep.EpochIndex()) + _, err = r.EpochByIndex(1) + require.ErrorIs(t, err, types.ErrPruned) +} diff --git a/sei-tendermint/internal/autobahn/epoch/testonly.go b/sei-tendermint/internal/autobahn/epoch/testonly.go index c02099160f..cbb672288e 100644 --- a/sei-tendermint/internal/autobahn/epoch/testonly.go +++ b/sei-tendermint/internal/autobahn/epoch/testonly.go @@ -21,3 +21,12 @@ func GenRegistry(rng utils.Rng, size int) (*Registry, []types.SecretKey) { registry := utils.OrPanic1(NewRegistry(committee, firstBlock, time.Now())) return registry, sks } + +// MustEpoch returns the registered epoch at i. Panics if it is missing. +func (r *Registry) MustEpoch(i types.EpochIndex) *types.Epoch { + ep, err := r.EpochByIndex(i) + if err != nil { + panic(err) + } + return ep +} diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 35af7e8ccf..1fd9e7539f 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -575,6 +575,7 @@ func TestProducer_LeaveCancelsAndRejoinStartsNewLane(t *testing.T) { } epLeave, err := registry.ActivateEpoch( + 0, map[types.PublicKey]uint64{b.Public(): 1}, time.Time{}, registry.FirstBlock(), ) @@ -603,6 +604,7 @@ func TestProducer_LeaveCancelsAndRejoinStartsNewLane(t *testing.T) { } epJoin, err := registry.ActivateEpoch( + epLeave.EpochIndex(), map[types.PublicKey]uint64{a.Public(): 1, b.Public(): 1}, time.Time{}, registry.FirstBlock(), ) @@ -648,6 +650,7 @@ func TestInsertTx_WaitUnblocksOnLeave(t *testing.T) { time.Sleep(20 * time.Millisecond) epLeave, err := registry.ActivateEpoch( + 0, map[types.PublicKey]uint64{b.Public(): 1}, time.Time{}, registry.FirstBlock(), ) diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 9ee114331b..fe05f51ffd 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -19,7 +19,7 @@ func TestAvailClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() env := newTestEnv(registry) var nodes []*testNode activeKeys := keys[:3] // keys are sorted by weight, so that's ok. diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index 6a2f1a95ea..284932c6c6 100644 --- a/sei-tendermint/internal/p2p/giga/consensus_test.go +++ b/sei-tendermint/internal/p2p/giga/consensus_test.go @@ -15,7 +15,7 @@ func TestConsensusClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 7) - committee := registry.LatestEpoch().Committee() + committee := registry.MustEpoch(0).Committee() env := newTestEnv(registry) // Run only a subset of replicas, to enforce timeouts. var nodes []*testNode diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index b929ea40dd..b85bfbdefd 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -58,7 +58,7 @@ type testEnv struct { } func newTestEnv(registry *epoch.Registry) *testEnv { - return &testEnv{registry, registry.LatestEpoch().Committee(), map[types.PublicKey]*testNode{}} + return &testEnv{registry, registry.MustEpoch(0).Committee(), map[types.PublicKey]*testNode{}} } // Call AddNode BEFORE Run. @@ -109,7 +109,7 @@ func TestDataClientServer(t *testing.T) { prev := utils.None[*types.CommitQC]() for i := range 3 { t.Logf("iteration %v", i) - qc, blocks := data.TestCommitQC(rng, server.data.Registry().LatestEpoch(), keys, prev) + qc, blocks := data.TestCommitQC(rng, server.data.Registry().MustEpoch(0), keys, prev) if err := server.data.PushQC(ctx, qc, blocks); err != nil { return fmt.Errorf("serverState.PushQC(): %w", err) } diff --git a/sei-tendermint/internal/p2p/giga_router_common_test.go b/sei-tendermint/internal/p2p/giga_router_common_test.go index a8168989a8..98a4eee33c 100644 --- a/sei-tendermint/internal/p2p/giga_router_common_test.go +++ b/sei-tendermint/internal/p2p/giga_router_common_test.go @@ -119,7 +119,7 @@ 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]()) + qc, blocks := data.TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*atypes.CommitQC]()) gr := qc.QC().GlobalRange() require.Greater(t, gr.Len(), 2) last := gr.First + atypes.GlobalBlockNumber(gr.Len()/2) From e00202c69d94b8bcdf8002611525d6c1c3c9ffff Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 19:18:16 -0700 Subject: [PATCH 14/19] docs(autobahn): laneQC is weighted under the applied epoch Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/inner.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index cd1c4d3080..81e46325d9 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -268,8 +268,7 @@ func (i *inner) dropLanes(lanes []types.LaneID) int { return n } -// laneQC returns the LaneQC for (lane, n) under the applied epoch's vote -// weighting (i.epoch), if one has formed. +// laneQC returns the LaneQC for (lane, n) under i.epoch, if one has formed. func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { votes, ok := i.votes[lane] if !ok { From 1632565962c435594b11201590b690cc6928fb9f Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 20:45:28 -0700 Subject: [PATCH 15/19] test(autobahn): fold duplicate multi-epoch tests into tables Drop twins of the idle-boundary and SetupInitialEpochs cases, and share one RunEpochAdvance leash fixture for registry, AppQC, and parked next-epoch QC. Co-authored-by: Cursor --- .../internal/autobahn/avail/state_test.go | 190 +++++++----------- .../internal/autobahn/data/state_test.go | 68 ------- .../internal/autobahn/epoch/registry_test.go | 135 +++++-------- 3 files changed, 122 insertions(+), 271 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 6cf1c03ddf..3c7a672381 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -746,44 +746,6 @@ func TestPushCommitQC_MidEpochNoWait(t *testing.T) { require.Equal(t, epoch.FirstRoad(f.m)+1, nextRoad(f.state)) } -func TestPushCommitQC_FutureEpochParksUntilAdvance(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - rng := utils.TestRng() - f := newSealFixture(t) - - prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) - qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) - require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) - setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) - - f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) - ep2 := f.registry.MustEpoch(f.m + 1) - qcNext := types.BuildCommitQC(ep2, f.keys, utils.Some(qcLast), nil) - require.Equal(t, epoch.FirstRoad(f.m+1), qcNext.Proposal().Index()) - - var pushErr error - go func() { pushErr = f.state.PushCommitQC(ctx, qcNext) }() - synctest.Wait() - require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex()) - require.Equal(t, epoch.LastRoad(f.m)+1, nextRoad(f.state), "future QC must stay parked") - - var runErr error - go func() { runErr = f.state.runEpochAdvance(ctx) }() - _, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { - return ep.EpochIndex() >= f.m+1 - }) - require.NoError(t, err) - synctest.Wait() - require.NoError(t, pushErr) - require.Equal(t, epoch.FirstRoad(f.m+1)+1, nextRoad(f.state)) - cancel() - synctest.Wait() - require.ErrorIs(t, runErr, context.Canceled) - }) -} - func TestPushCommitQC_StaleAfterAdvanceSoftDrops(t *testing.T) { rng := utils.TestRng() registry, keys := epoch.GenRegistry(rng, 4) @@ -880,64 +842,82 @@ func TestWaitUntilApplied_ParksUntilEpochAdvance(t *testing.T) { }) } -func TestRunEpochAdvance_AdvancesWhenBothLeashesMet(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - rng := utils.TestRng() - f := newSealFixture(t) - f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) - - prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) - qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) - require.Equal(t, epoch.LastRoad(f.m), qcLast.Proposal().Index()) - require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) - setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) - require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex()) - - var runErr error - go func() { runErr = f.state.runEpochAdvance(ctx) }() - - ep, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { - return ep.EpochIndex() >= f.m+1 - }) - require.NoError(t, err) - require.Equal(t, f.m+1, ep.EpochIndex()) - require.Equal(t, f.m+1, f.state.Epoch().Load().EpochIndex()) - - cancel() - synctest.Wait() - require.ErrorIs(t, runErr, context.Canceled) - }) -} +func TestRunEpochAdvance_Leashes(t *testing.T) { + type missing int + const ( + none missing = iota + registry + appQC + ) + for _, tc := range []struct { + name string + missing missing + parkNextQC bool + }{ + {name: "both met parks future QC until advance", missing: none, parkNextQC: true}, + {name: "waits for registry", missing: registry}, + {name: "waits for AppQC", missing: appQC}, + } { + t.Run(tc.name, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + rng := utils.TestRng() + f := newSealFixture(t) + if tc.missing != registry { + f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) + } -func TestRunEpochAdvance_WaitsForRegistry(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - rng := utils.TestRng() - f := newSealFixture(t) + prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) + qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) + require.Equal(t, epoch.LastRoad(f.m), qcLast.Proposal().Index()) + require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) + if tc.missing != appQC { + setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) + } + require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex()) + + var pushErr error + if tc.parkNextQC { + ep2 := f.registry.MustEpoch(f.m + 1) + qcNext := types.BuildCommitQC(ep2, f.keys, utils.Some(qcLast), nil) + require.Equal(t, epoch.FirstRoad(f.m+1), qcNext.Proposal().Index()) + go func() { pushErr = f.state.PushCommitQC(ctx, qcNext) }() + synctest.Wait() + require.Equal(t, epoch.LastRoad(f.m)+1, nextRoad(f.state), "future QC must stay parked") + } - prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) - qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) - require.Equal(t, epoch.LastRoad(f.m), qcLast.Proposal().Index()) - require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) - setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) + var runErr error + go func() { runErr = f.state.runEpochAdvance(ctx) }() + if tc.missing != none { + synctest.Wait() + require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex()) + switch tc.missing { + case registry: + f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) + case appQC: + setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) + } + } - var runErr error - go func() { runErr = f.state.runEpochAdvance(ctx) }() - synctest.Wait() - require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex(), "parked on WaitForEpoch(M+1)") + ep, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + return ep.EpochIndex() >= f.m+1 + }) + require.NoError(t, err) + require.Equal(t, f.m+1, ep.EpochIndex()) + require.Equal(t, f.m+1, f.state.Epoch().Load().EpochIndex()) + if tc.parkNextQC { + synctest.Wait() + require.NoError(t, pushErr) + require.Equal(t, epoch.FirstRoad(f.m+1)+1, nextRoad(f.state)) + } - f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) - _, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { - return ep.EpochIndex() >= f.m+1 + cancel() + synctest.Wait() + require.ErrorIs(t, runErr, context.Canceled) + }) }) - require.NoError(t, err) - cancel() - synctest.Wait() - require.ErrorIs(t, runErr, context.Canceled) - }) + } } // A durable-tip catch-up refreshes ConsensusSpec at the persist write site, @@ -984,31 +964,3 @@ func TestMarkCommitQCsPersisted_RefreshesSpecWhileEpochAdvanceWaitsForRegistry(t require.ErrorIs(t, advanceErr, context.Canceled) }) } - -func TestRunEpochAdvance_WaitsForAppQC(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - rng := utils.TestRng() - f := newSealFixture(t) - f.registry.AdvanceIfNeeded(epoch.LastRoad(f.m)) - - prev := tipLink(f.ep, f.keys[0], epoch.LastRoad(f.m)-1) - qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) - require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) - - var runErr error - go func() { runErr = f.state.runEpochAdvance(ctx) }() - synctest.Wait() - require.Equal(t, f.m, f.state.Epoch().Load().EpochIndex(), "parked without AppQC covering M") - - setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) - _, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { - return ep.EpochIndex() >= f.m+1 - }) - require.NoError(t, err) - cancel() - synctest.Wait() - require.ErrorIs(t, runErr, context.Canceled) - }) -} diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 0c722b805c..7f35c7e665 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -129,27 +129,6 @@ func commitQCAtRoad( return types.NewFullCommitQC(types.NewCommitQC(votes), []*types.BlockHeader{block.Header()}), []*types.Block{block} } -func TestNextCommitEpoch_TracksNextRoadEpoch(t *testing.T) { - ctx := t.Context() - rng := utils.TestRng() - registry, keys := epoch.GenRegistry(rng, 3) - ep0 := registry.MustEpoch(0) - state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) - require.Equal(t, ep0, state.NextCommitEpoch().Load()) - - require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - s.SpawnBgNamed("state.Run()", func() error { - return utils.IgnoreCancel(state.Run(ctx)) - }) - qc, blocks := TestCommitQC(rng, ep0, keys, utils.None[*types.CommitQC]()) - if err := state.PushQC(ctx, qc, blocks); err != nil { - return err - } - require.Equal(t, ep0, state.NextCommitEpoch().Load()) - return nil - })) -} - func TestNextCommitEpoch_AdvancesAtIdleEpochBoundary(t *testing.T) { ctx := t.Context() rng := utils.TestRng() @@ -488,53 +467,6 @@ func TestPushAppHashRejectsJumpOverCommitQCRange(t *testing.T) { })) } -func TestPushAppHash_AdvancesRegistryAtEpochBoundary(t *testing.T) { - ctx := t.Context() - rng := utils.TestRng() - - t.Run("mid-epoch road does not seed epoch 2", func(t *testing.T) { - registry, keys := epoch.GenRegistry(rng, 3) - state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) - require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) - qc, blocks := TestCommitQC(rng, registry.MustEpoch(0), keys, utils.None[*types.CommitQC]()) - if err := state.PushQC(ctx, qc, blocks); err != nil { - return err - } - if err := state.PushAppHash(ctx, qc.QC().GlobalRange().Next-1, types.GenAppHash(rng)); err != nil { - return err - } - if _, err := registry.EpochAt(epoch.FirstRoad(2)); err == nil { - return fmt.Errorf("epoch 2 must stay absent for road %d", qc.QC().Proposal().Index()) - } - return nil - })) - }) - - t.Run("LastRoad does not seed epoch 2", func(t *testing.T) { - registry, keys := epoch.GenRegistry(rng, 3) - state := newTestState(t, &Config{Registry: registry}, newTestBlockDB(t, t.TempDir())) - ep := registry.MustEpoch(0) - require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) - qc, blocks := commitQCAtRoad(ep, keys, epoch.LastRoad(0), ep.FirstBlock()) - if qc.QC().Proposal().Index() != epoch.LastRoad(0) { - return fmt.Errorf("road = %d, want %d", qc.QC().Proposal().Index(), epoch.LastRoad(0)) - } - if err := state.PushQC(ctx, qc, blocks); err != nil { - return err - } - if err := state.PushAppHash(ctx, qc.QC().GlobalRange().Next-1, types.GenAppHash(rng)); err != nil { - return err - } - if _, err := registry.EpochAt(epoch.FirstRoad(2)); err == nil { - return fmt.Errorf("PushAppHash at LastRoad(0) must not seed epoch 2") - } - return nil - })) - }) -} - func TestPushBlockAcceptsBlockWithQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() diff --git a/sei-tendermint/internal/autobahn/epoch/registry_test.go b/sei-tendermint/internal/autobahn/epoch/registry_test.go index 8544af5f9b..5520e60af8 100644 --- a/sei-tendermint/internal/autobahn/epoch/registry_test.go +++ b/sei-tendermint/internal/autobahn/epoch/registry_test.go @@ -38,31 +38,22 @@ func TestRegistry_EpochByIndex_UnknownReturnsNotFound(t *testing.T) { } } -func TestRegistry_EpochByIndex_GenesisFound(t *testing.T) { +func TestNewRegistry_Genesis(t *testing.T) { r, _ := makeRegistry(t) - ep, err := r.EpochByIndex(0) - if err != nil { - t.Fatal(err) - } - if ep.EpochIndex() != 0 { - t.Fatalf("EpochIndex() = %d, want 0", ep.EpochIndex()) - } -} + ep0, err := r.EpochByIndex(0) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(0), ep0.EpochIndex()) -func TestNewRegistry_GenesisEpochBoundedRange(t *testing.T) { - r, _ := makeRegistry(t) - ep0, err := r.EpochAt(0) - if err != nil { - t.Fatalf("EpochAt(0): %v", err) - } - rng0 := ep0.RoadRange() + epAt, err := r.EpochAt(LastRoad(0)) + require.NoError(t, err) + require.Equal(t, types.EpochIndex(0), epAt.EpochIndex()) + rng0 := epAt.RoadRange() if rng0.First != 0 || rng0.Next != FirstRoad(1) { t.Fatalf("epoch 0 RoadRange = {%d, %d}, want {0, %d}", rng0.First, rng0.Next, FirstRoad(1)) } + ep1, err := r.EpochAt(FirstRoad(1)) - if err != nil { - t.Fatalf("EpochAt(FirstRoad(1)): %v", err) - } + require.NoError(t, err) rng1 := ep1.RoadRange() if rng1.First != FirstRoad(1) || rng1.Next != FirstRoad(2) { t.Fatalf("epoch 1 RoadRange = {%d, %d}, want {%d, %d}", rng1.First, rng1.Next, FirstRoad(1), FirstRoad(2)) @@ -72,17 +63,6 @@ func TestNewRegistry_GenesisEpochBoundedRange(t *testing.T) { } } -func TestEpochAt_WithinGenesisEpoch(t *testing.T) { - r, _ := makeRegistry(t) - ep, err := r.EpochAt(LastRoad(0)) - if err != nil { - t.Fatalf("EpochAt(LastRoad(0)) error: %v", err) - } - if ep.EpochIndex() != 0 { - 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)) @@ -113,60 +93,47 @@ func TestEpochAt_FoundAfterAdvanceIfNeeded(t *testing.T) { } } -func TestSetupInitialEpochs_EmptyNoneIsNoOp(t *testing.T) { - r, _ := makeRegistry(t) - r.SetupInitialEpochs(utils.None[types.RoadRange]()) - for _, idx := range []types.EpochIndex{0, 1} { - if _, err := r.EpochAt(FirstRoad(idx)); err != nil { - t.Fatalf("EpochAt(epoch %d) after empty None: %v", idx, err) - } - } - if _, err := r.EpochAt(FirstRoad(2)); err == nil { - t.Fatal("EpochAt(epoch 2) should not be present from empty None") - } -} - -func TestSetupInitialEpochs_CommitQCMidSeedsPlaceholderNext(t *testing.T) { - r, _ := makeRegistry(t) - tip := midRoad(5) - r.SetupInitialEpochs(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - for _, idx := range []types.EpochIndex{4, 5, 6} { - if _, err := r.EpochAt(FirstRoad(idx)); err != nil { - t.Fatalf("EpochAt(epoch %d) after CommitQC seeding: %v", idx, err) - } - } - if _, err := r.EpochAt(FirstRoad(7)); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present from mid-epoch CommitQC") - } -} - -func TestSetupInitialEpochs_CommitQCClosingSeedsNext(t *testing.T) { - r, _ := makeRegistry(t) - tip := LastRoad(5) - r.SetupInitialEpochs(utils.Some(types.RoadRange{First: tip, Next: tip + 1})) - for _, idx := range []types.EpochIndex{4, 5, 6} { - if _, err := r.EpochAt(FirstRoad(idx)); err != nil { - t.Fatalf("EpochAt(epoch %d) after closing CommitQC: %v", idx, err) - } - } - if _, err := r.EpochAt(FirstRoad(7)); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present past windowLast+1") - } -} - -func TestSetupInitialEpochs_CommitSpanFromFirst(t *testing.T) { - r, _ := makeRegistry(t) - r.SetupInitialEpochs(utils.Some(types.RoadRange{ - First: midRoad(2), - Next: midRoad(5) + 1, - })) - for _, idx := range []types.EpochIndex{1, 2, 3, 4, 5, 6} { - if _, err := r.EpochAt(FirstRoad(idx)); err != nil { - t.Fatalf("EpochAt(epoch %d) after commit span seeding: %v", idx, err) - } - } - if _, err := r.EpochAt(FirstRoad(7)); err == nil { - t.Fatal("EpochAt(epoch 7) should not be present past placeholder windowLast+1") +func TestSetupInitialEpochs(t *testing.T) { + for _, tc := range []struct { + name string + span utils.Option[types.RoadRange] + want []types.EpochIndex + absent types.EpochIndex + }{ + { + name: "empty None is no-op", + span: utils.None[types.RoadRange](), + want: []types.EpochIndex{0, 1}, + absent: 2, + }, + { + name: "mid CommitQC seeds placeholder next", + span: utils.Some(types.RoadRange{First: midRoad(5), Next: midRoad(5) + 1}), + want: []types.EpochIndex{4, 5, 6}, + absent: 7, + }, + { + name: "commit span from first", + span: utils.Some(types.RoadRange{ + First: midRoad(2), + Next: midRoad(5) + 1, + }), + want: []types.EpochIndex{1, 2, 3, 4, 5, 6}, + absent: 7, + }, + } { + t.Run(tc.name, func(t *testing.T) { + r, _ := makeRegistry(t) + r.SetupInitialEpochs(tc.span) + for _, idx := range tc.want { + if _, err := r.EpochAt(FirstRoad(idx)); err != nil { + t.Fatalf("EpochAt(epoch %d): %v", idx, err) + } + } + if _, err := r.EpochAt(FirstRoad(tc.absent)); err == nil { + t.Fatalf("EpochAt(epoch %d) should not be present", tc.absent) + } + }) } } From ac45f903753b8e3980603a5359d6eb03f71f325f Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 21:02:59 -0700 Subject: [PATCH 16/19] docs(autobahn): LaneProposal.Verify is integrity, not mechanism Stop documenting payload hash and membership in the godoc; those are implementation and caller concerns. Co-authored-by: Cursor --- sei-tendermint/autobahn/types/lane_proposal.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sei-tendermint/autobahn/types/lane_proposal.go b/sei-tendermint/autobahn/types/lane_proposal.go index d250708ee1..6063abb419 100644 --- a/sei-tendermint/autobahn/types/lane_proposal.go +++ b/sei-tendermint/autobahn/types/lane_proposal.go @@ -22,8 +22,7 @@ func NewLaneProposal(block *Block) *LaneProposal { // Block . func (m *LaneProposal) Block() *Block { return m.block } -// Verify checks the proposal's internal integrity (payload hash). Committee -// membership is separate: a lane is not tied to a single committee/epoch. +// Verify checks the proposal's internal integrity. func (m *LaneProposal) Verify() error { return m.block.Verify() } From fa5fb0ed2a50f12357bf0a7afe566690860d59df Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 21:49:08 -0700 Subject: [PATCH 17/19] refactor(autobahn): apply epochs via ConsensusSpec.Epoch Drop a separate applied watch. Seal waits until the last CommitQC is durable so advance never publishes a stale tip with the next epoch. Co-authored-by: Cursor --- .../internal/autobahn/avail/inner.go | 89 +++++++------------ .../internal/autobahn/avail/inner_test.go | 6 +- .../internal/autobahn/avail/state.go | 50 +++++++---- .../internal/autobahn/avail/state_test.go | 9 +- .../internal/autobahn/avail/subscriptions.go | 2 +- .../autobahn/avail/subscriptions_test.go | 2 +- .../internal/autobahn/avail/testonly.go | 18 ++++ 7 files changed, 91 insertions(+), 85 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 81e46325d9..721febc42c 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -6,27 +6,25 @@ import ( "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/libs/utils" ) // inner holds roads and per-LaneID block/vote maps. type inner struct { persistedCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] // latest persisted CommitQC - consensusSpec utils.AtomicSend[types.ConsensusSpec] - roads *queue[types.RoadIndex, *road] + // consensusSpec.Epoch is the applied (next-CommitQC) epoch. advanceEpoch is + // the sole writer after construction. blockVotes are weighted under it. + // CommitQC may lag that epoch while withheld at LastRoad (durable tip is + // persistedCommitQC). + consensusSpec utils.AtomicSend[types.ConsensusSpec] + roads *queue[types.RoadIndex, *road] - // epoch is the applied (next-CommitQC) epoch. advanceEpoch is the sole - // writer after construction. Distinct from consensusSpec.Epoch, which is the - // epoch of the RoadIndex after the publishable tip and may lag while withheld. - // blockVotes are always weighted under this epoch. - epoch utils.AtomicSend[*types.Epoch] // anchorEpoch is the epoch of data's Anchor CommitQC when one exists. // None until the first Anchor arrives (construction prune or runEvict). // It may exceed the applied epoch while runEpochAdvance is parked on // WaitForEpoch, or briefly between prune and the next advance: admission // falls back to the Anchor committee via epochForVote / epochForLane. - // prune never advances i.epoch — only advanceEpoch does. + // prune never advances applied — only advanceEpoch does. anchorEpoch utils.Option[*types.Epoch] blocks map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]] votes map[types.LaneID]*queue[types.BlockNumber, *blockVotes] @@ -65,7 +63,6 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: genesis}), roads: newQueue[types.RoadIndex, *road](), - epoch: utils.NewAtomicSend(genesis), blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, @@ -149,39 +146,45 @@ func newInner(ds *data.State, loaded *loadedState) (*inner, error) { return i, nil } +func (i *inner) applied() *types.Epoch { + return i.consensusSpec.Load().Epoch +} + // seedApplied sets applied to ep and opens its lanes. Construction only; // live advances go through advanceEpoch. func (i *inner) seedApplied(ep *types.Epoch) { for lane := range ep.Committee().Lanes().All() { i.addLane(lane) } - i.epoch.Store(ep) + spec := i.consensusSpec.Load() + i.consensusSpec.Store(types.ConsensusSpec{CommitQC: spec.CommitQC, Epoch: ep}) } // advanceEpoch makes ep the applied epoch: opens its lanes, reweights votes, -// and republishes ConsensusSpec. +// and publishes ConsensusSpec for the durable tip. func (i *inner) advanceEpoch(ep *types.Epoch) { for lane := range ep.Committee().Lanes().All() { i.addLane(lane) } - // Publish applied epoch before reweight so reweightVotes reads i.epoch. - // Callers hold the avail lock, so Epoch() waiters cannot observe votes - // between the Store and the reweight. - i.epoch.Store(ep) + i.consensusSpec.Store(types.ConsensusSpec{CommitQC: i.persistedCommitQC.Load(), Epoch: ep}) i.reweightVotes() - i.refreshConsensusSpec() } // canAdvanceEpoch reports whether the applied epoch is sealed and its prune leash is -// met. Sealed means roads hold the epoch's last CommitQC. The prune leash is met -// when the Anchor epoch covers the applied epoch (an AppQC for that epoch -// exists). The execution leash — registry contains the next epoch — is checked +// met. Sealed means roads and persistedCommitQC hold the epoch's last CommitQC. +// Waiting on persist keeps applied in lockstep with consensusSpec.Epoch. +// The prune leash is met when the Anchor epoch covers the applied epoch. +// The execution leash — registry contains the next epoch — is checked // separately so live waiters are not parked on avail's lock for a registry update. func (i *inner) canAdvanceEpoch() bool { - ep := i.epoch.Load() + ep := i.applied() if i.roads.next < ep.RoadRange().Next { return false } + tip, ok := i.persistedCommitQC.Load().Get() + if !ok || tip.Index()+1 < ep.RoadRange().Next { + return false + } ae, ok := i.anchorEpoch.Get() return ok && ae.EpochIndex() >= ep.EpochIndex() } @@ -191,7 +194,7 @@ func (i *inner) canAdvanceEpoch() bool { // violation (execution leash should already have registered it). func (i *inner) advanceReadyEpochs(ds *data.State) error { for i.canAdvanceEpoch() { - nextIdx := i.epoch.Load().EpochIndex() + 1 + nextIdx := i.applied().EpochIndex() + 1 next, err := ds.Registry().EpochByIndex(nextIdx) if err != nil { return fmt.Errorf("epoch %d with seal+prune leashes met: %w", nextIdx, err) @@ -201,15 +204,9 @@ func (i *inner) advanceReadyEpochs(ds *data.State) error { return nil } -// refreshConsensusSpec publishes ConsensusSpec for the durable tip, paired with -// the epoch of the RoadIndex that follows it. The spec is withheld — the -// previously published one stands — until that epoch is applied and resolvable. -// -// Withholding rather than publishing an earlier tip is what keeps the spec -// monotonic. At an epoch boundary the durable tip sits on LastRoad(E) while -// applied is still E, and a node that already entered E+1 before a restart must -// not be handed a predecessor of the tip it holds: advancing to it would roll the -// tip backwards and discard that view's votes. +// refreshConsensusSpec publishes the durable tip when the following RoadIndex +// sits in the applied epoch. Otherwise the previous spec stands (withhold at +// LastRoad until advanceEpoch). It does not change Epoch; advanceEpoch does. func (i *inner) refreshConsensusSpec() { tip := i.persistedCommitQC.Load() cqc, ok := tip.Get() @@ -217,29 +214,9 @@ func (i *inner) refreshConsensusSpec() { return } next := cqc.Index() + 1 - ep := i.epoch.Load() - if epoch.IndexForRoad(next) > ep.EpochIndex() { - return - } + ep := i.applied() if !ep.RoadRange().Has(next) { - // Persist may lag advanceEpoch: tip's next RoadIndex can sit in an - // earlier epoch still present on some admitted road. - found := false - if next >= i.roads.first && next < i.roads.next { - ep = i.roads.q[next].epoch - found = true - } else { - for idx := i.roads.first; idx < i.roads.next; idx++ { - if r := i.roads.q[idx].epoch; r.RoadRange().Has(next) { - ep = r - found = true - break - } - } - } - if !found { - return - } + return } i.consensusSpec.Store(types.ConsensusSpec{CommitQC: tip, Epoch: ep}) } @@ -268,7 +245,7 @@ func (i *inner) dropLanes(lanes []types.LaneID) int { return n } -// laneQC returns the LaneQC for (lane, n) under i.epoch, if one has formed. +// laneQC returns the LaneQC for (lane, n) under the applied epoch, if one has formed. func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*types.LaneQC] { votes, ok := i.votes[lane] if !ok { @@ -281,9 +258,9 @@ func (i *inner) laneQC(lane types.LaneID, n types.BlockNumber) utils.Option[*typ return entry.qc } -// reweightVotes recounts retained block votes under the applied epoch (i.epoch). +// reweightVotes recounts retained block votes under the applied epoch. func (i *inner) reweightVotes() { - ep := i.epoch.Load() + ep := i.applied() for _, vq := range i.votes { for n := vq.first; n < vq.next; n++ { vq.q[n].reweight(ep) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index e22e2d9b5f..ba864854a5 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -278,7 +278,6 @@ func TestAdvanceReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep0}), roads: newQueue[types.RoadIndex, *road](), - epoch: utils.NewAtomicSend(ep0), anchorEpoch: utils.Some(anchor.Epoch), blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, @@ -291,13 +290,12 @@ func TestAdvanceReadyEpochs_BoundaryTipUsesDataAppQC(t *testing.T) { i.roads.next = last i.roads.pushBack(newRoad(qcLast, ep0)) i.persistedCommitQC.Store(utils.Some(qcLast)) - i.epoch.Store(ep0) require.False(t, i.roads.q[last].appQC.IsPresent(), "road AppQC empty; prune leash is the Anchor") require.True(t, i.canAdvanceEpoch()) require.NoError(t, i.advanceReadyEpochs(ds)) - require.Equal(t, ep1.EpochIndex(), i.epoch.Load().EpochIndex()) + require.Equal(t, ep1.EpochIndex(), i.applied().EpochIndex()) spec := i.consensusSpec.Load() cqc, ok := spec.CommitQC.Get() require.True(t, ok) @@ -324,7 +322,6 @@ func TestAdvanceReadyEpochs_MissingNextEpochErrors(t *testing.T) { persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep1}), roads: newQueue[types.RoadIndex, *road](), - epoch: utils.NewAtomicSend(ep1), anchorEpoch: utils.Some(ep1), blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, @@ -364,7 +361,6 @@ func TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied(t *testing.T persistedCommitQC: utils.NewAtomicSend(utils.None[*types.CommitQC]()), consensusSpec: utils.NewAtomicSend(types.ConsensusSpec{CommitQC: utils.None[*types.CommitQC](), Epoch: ep0}), roads: newQueue[types.RoadIndex, *road](), - epoch: utils.NewAtomicSend(ep0), blocks: map[types.LaneID]*queue[types.BlockNumber, *types.Signed[*types.LaneProposal]]{}, votes: map[types.LaneID]*queue[types.BlockNumber, *blockVotes]{}, nextBlockToPersist: map[types.LaneID]types.BlockNumber{}, diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 1d5fd143e1..349c722d22 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -39,8 +39,7 @@ type State struct { key types.SecretKey data *data.State inner utils.Watch[*inner] - // epoch is a Load-only view of inner.epoch (applied / next-CommitQC epoch). - epoch utils.AtomicRecv[*types.Epoch] + spec utils.AtomicRecv[types.ConsensusSpec] // persisters groups all disk persistence components. // Always initialized: real when stateDir is set, no-op otherwise. @@ -51,9 +50,24 @@ func (s *State) PublicKey() types.PublicKey { return s.key.Public() } +// appliedEpoch is Load/Wait over consensusSpec.Epoch. +type appliedEpoch struct { + spec utils.AtomicRecv[types.ConsensusSpec] +} + +func (e appliedEpoch) Load() *types.Epoch { return e.spec.Load().Epoch } + +func (e appliedEpoch) Wait(ctx context.Context, pred func(*types.Epoch) bool) (*types.Epoch, error) { + sp, err := e.spec.Wait(ctx, func(sp types.ConsensusSpec) bool { return pred(sp.Epoch) }) + if err != nil { + return nil, err + } + return sp.Epoch, nil +} + // Epoch returns the applied (next-CommitQC) epoch. runEpochAdvance advances it. -func (s *State) Epoch() utils.AtomicRecv[*types.Epoch] { - return s.epoch +func (s *State) Epoch() appliedEpoch { + return appliedEpoch{s.spec} } func (s *State) LocalLane() utils.Option[types.LaneID] { @@ -61,7 +75,7 @@ func (s *State) LocalLane() utils.Option[types.LaneID] { } func (s *State) Lane(pk types.PublicKey) utils.Option[types.LaneID] { - return s.epoch.Load().Committee().Lane(pk) + return s.Epoch().Load().Committee().Lane(pk) } func (s *State) WaitForLocalLane(ctx context.Context) (types.LaneID, error) { @@ -69,7 +83,7 @@ func (s *State) WaitForLocalLane(ctx context.Context) (types.LaneID, error) { } func (s *State) WaitUntilClosed(ctx context.Context, lane types.LaneID) error { - _, err := s.epoch.Wait(ctx, func(ep *types.Epoch) bool { + _, err := s.Epoch().Wait(ctx, func(ep *types.Epoch) bool { return ep.IsClosed(lane) }) return err @@ -133,7 +147,7 @@ func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[strin key: key, data: data, inner: utils.NewWatch(inner), - epoch: inner.epoch.Subscribe(), + spec: inner.consensusSpec.Subscribe(), persisters: pers, }, nil } @@ -176,10 +190,7 @@ func (s *State) LastCommitQC() utils.AtomicRecv[utils.Option[*types.CommitQC]] { // with the epoch of the RoadIndex that follows it. CommitQC is None before // the first tip; until then Epoch is genesis epoch 0. func (s *State) SubscribeConsensusSpec() utils.AtomicRecv[types.ConsensusSpec] { - for inner := range s.inner.Lock() { - return inner.consensusSpec.Subscribe() - } - panic("unreachable") + return s.spec } func (s *State) appQC(ctx context.Context, idx types.RoadIndex) (*types.AppQC, error) { @@ -223,7 +234,7 @@ func (s *State) CommitQC(ctx context.Context, idx types.RoadIndex) (*types.Commi // waitUntilAdvanced blocks until the applied (next-CommitQC) epoch equals i. // Returns ErrPruned if applied has already passed i (see types.ErrPruned). func (s *State) waitUntilAdvanced(ctx context.Context, i types.EpochIndex) (*types.Epoch, error) { - epoch, err := s.epoch.Wait(ctx, func(epoch *types.Epoch) bool { + epoch, err := s.Epoch().Wait(ctx, func(epoch *types.Epoch) bool { return i <= epoch.EpochIndex() }) if err != nil { @@ -440,7 +451,7 @@ func (s *State) PushVote(ctx context.Context, vote *types.Signed[*types.LaneVote if err := vote.Msg().Verify(ep.Committee()); err != nil { return nil } - applied := inner.epoch.Load() + applied := inner.applied() for q.next <= n { q.pushBack(newBlockVotes()) } @@ -461,7 +472,7 @@ func epochForVote(inner *inner, vote *types.Signed[*types.LaneVote]) utils.Optio c := ep.Committee() return c.HasLane(lane) && c.HasReplica(key) } - applied := inner.epoch.Load() + applied := inner.applied() if belongs(applied) { return utils.Some(applied) } @@ -475,7 +486,7 @@ func epochForVote(inner *inner, vote *types.Signed[*types.LaneVote]) utils.Optio // epochForLane returns the applied epoch if it has lane, otherwise the Anchor // epoch when that is a different EpochIndex and has lane. func epochForLane(inner *inner, lane types.LaneID) utils.Option[*types.Epoch] { - applied := inner.epoch.Load() + applied := inner.applied() if applied.Committee().HasLane(lane) { return utils.Some(applied) } @@ -698,12 +709,12 @@ func (s *State) runEvict(ctx context.Context) error { }) } -// runEpochAdvance is the sole writer of inner.epoch after construction. It waits +// runEpochAdvance is the sole writer of applied epoch (consensusSpec.Epoch) after construction. It waits // for the execution leash on the registry, then seal and the prune leash on // avail's inner watch (canAdvanceEpoch), and advances one epoch per wake. func (s *State) runEpochAdvance(ctx context.Context) error { for { - next := s.epoch.Load().EpochIndex() + 1 + next := s.Epoch().Load().EpochIndex() + 1 // ErrPruned is not expected here: PushCommitQC withholds a CommitQC // until its epoch is applied, so the Anchor never leads applied by more // than one epoch and PruneBefore cannot drop next. @@ -717,7 +728,7 @@ func (s *State) runEpochAdvance(ctx context.Context) error { }); err != nil { return err } - if got := inner.epoch.Load().EpochIndex(); got+1 != next { + if got := inner.applied().EpochIndex(); got+1 != next { return fmt.Errorf("runEpochAdvance: applied %d, want %d before advance", got, next-1) } inner.advanceEpoch(ep) @@ -815,9 +826,10 @@ func (s *State) setNextBlockToPersist(lane types.LaneID, next types.BlockNumber) // ConsensusSpec is refreshed here so tip catch-up stays visible even while // runEpochAdvance is parked on WaitForEpoch. func (s *State) markCommitQCsPersisted(qc *types.CommitQC) { - for inner := range s.inner.Lock() { + for inner, ctrl := range s.inner.Lock() { inner.persistedCommitQC.Store(utils.Some(qc)) inner.refreshConsensusSpec() + ctrl.Updated() } } diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 3c7a672381..4e1c4d1279 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -144,6 +144,7 @@ func TestPrune_AnchorEpochDropsClosedLane(t *testing.T) { // Anchor. Advance the seal cursor without admitting LastRoad tips — runEvict // is not running, and the rest of this test expects empty roads. seekRoads(state, epoch.FirstRoad(1)) + persistEpochSeal(state, ep0, keys) if _, err := state.Epoch().Wait(ctx, func(ep *types.Epoch) bool { return ep.EpochIndex() >= 1 }); err != nil { @@ -155,6 +156,7 @@ func TestPrune_AnchorEpochDropsClosedLane(t *testing.T) { ctrl.Updated() } seekRoads(state, epoch.FirstRoad(2)) + persistEpochSeal(state, ep1, keys) if _, err := state.Epoch().Wait(ctx, func(ep *types.Epoch) bool { return ep.EpochIndex() >= epLeave.EpochIndex() }); err != nil { @@ -678,8 +680,8 @@ func TestHeaders_WaitsForPrevEpochLaneVote(t *testing.T) { } for inner := range state.inner.Lock() { - if inner.epoch.Load().EpochIndex() != epLeave.EpochIndex() { - return fmt.Errorf("applied epoch = %d, want %d", inner.epoch.Load().EpochIndex(), epLeave.EpochIndex()) + if inner.applied().EpochIndex() != epLeave.EpochIndex() { + return fmt.Errorf("applied epoch = %d, want %d", inner.applied().EpochIndex(), epLeave.EpochIndex()) } ae, ok := inner.anchorEpoch.Get() if !ok { @@ -872,6 +874,7 @@ func TestRunEpochAdvance_Leashes(t *testing.T) { qcLast := types.BuildCommitQC(f.ep, f.keys, utils.Some(prev), nil) require.Equal(t, epoch.LastRoad(f.m), qcLast.Proposal().Index()) require.NoError(t, f.state.PushCommitQC(ctx, qcLast)) + f.state.markCommitQCsPersisted(qcLast) if tc.missing != appQC { setRoadAppQC(f.state, qcLast.Index(), data.TestAppQC(f.keys, types.NewAppProposal(qcLast.Proposal(), types.GenAppHash(rng)))) } @@ -900,7 +903,7 @@ func TestRunEpochAdvance_Leashes(t *testing.T) { } } - ep, err := f.state.epoch.Wait(t.Context(), func(ep *types.Epoch) bool { + ep, err := f.state.Epoch().Wait(t.Context(), func(ep *types.Epoch) bool { return ep.EpochIndex() >= f.m+1 }) require.NoError(t, err) diff --git a/sei-tendermint/internal/autobahn/avail/subscriptions.go b/sei-tendermint/internal/autobahn/avail/subscriptions.go index 94ec4a4de9..2dbf4bc57d 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions.go @@ -48,7 +48,7 @@ func (r *LaneProposalsRecv) Recv(ctx context.Context) (*types.Signed[*types.Lane // If exclude is Some, also requires the LaneID to differ (e.g. after the // previous identity was closed and a new LaneID allocated). func (s *State) WaitForNextLane(ctx context.Context, pk types.PublicKey, exclude utils.Option[types.LaneID]) (types.LaneID, error) { - ep, err := s.epoch.Wait(ctx, func(ep *types.Epoch) bool { + ep, err := s.Epoch().Wait(ctx, func(ep *types.Epoch) bool { got, ok := ep.Committee().Lane(pk).Get() if !ok { return false diff --git a/sei-tendermint/internal/autobahn/avail/subscriptions_test.go b/sei-tendermint/internal/autobahn/avail/subscriptions_test.go index 5e825483ed..52769a325d 100644 --- a/sei-tendermint/internal/autobahn/avail/subscriptions_test.go +++ b/sei-tendermint/internal/autobahn/avail/subscriptions_test.go @@ -103,7 +103,7 @@ func TestSubscribeLaneProposals_StayLeaveRejoin(t *testing.T) { for inner, ctrl := range state.inner.Lock() { require.Greater(t, inner.roads.next, inner.roads.first) tip := inner.roads.q[inner.roads.next-1].commitQC - ep := inner.epoch.Load() + ep := inner.applied() require.Equal(t, epLeave.EpochIndex(), ep.EpochIndex()) require.True(t, ep.IsClosed(lane0)) n := inner.prune(data.Anchor{ diff --git a/sei-tendermint/internal/autobahn/avail/testonly.go b/sei-tendermint/internal/autobahn/avail/testonly.go index 1b7b4165e1..c024e23cdb 100644 --- a/sei-tendermint/internal/autobahn/avail/testonly.go +++ b/sei-tendermint/internal/autobahn/avail/testonly.go @@ -80,6 +80,23 @@ func RunTestNetwork(ctx context.Context, states []*State) error { }) } +func persistEpochSeal(s *State, ep *types.Epoch, keys []types.SecretKey) { + last := ep.RoadRange().Next - 1 + cks := make([]types.SecretKey, 0, len(keys)) + for _, k := range keys { + if ep.Committee().HasReplica(k.Public()) { + cks = append(cks, k) + } + } + var qc *types.CommitQC + if last == 0 { + qc = types.BuildCommitQC(ep, cks, utils.None[*types.CommitQC](), nil) + } else { + qc = types.BuildCommitQC(ep, cks, utils.Some(tipLink(ep, cks[0], last-1)), nil) + } + s.markCommitQCsPersisted(qc) +} + func seekRoads(s *State, idx types.RoadIndex) { for inner, ctrl := range s.inner.Lock() { inner.roads.first = idx @@ -139,6 +156,7 @@ func DriveAdvance(ctx context.Context, state *State, keys []types.SecretKey, wan if err := state.PushCommitQC(ctx, qc); err != nil { return err } + state.markCommitQCsPersisted(qc) setRoadAppQC(state, qc.Index(), data.TestAppQC(cks, types.NewAppProposal(qc.Proposal(), types.AppHash{}))) if _, err := state.Epoch().Wait(ctx, func(ep *types.Epoch) bool { return ep.EpochIndex() > cur.EpochIndex() From 97bdf50f6ee8c8b29b36af4a52eba6ca973bbd78 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 21:58:06 -0700 Subject: [PATCH 18/19] docs(autobahn): blockVotes stay weighted under applied epoch Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/avail/inner.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner.go b/sei-tendermint/internal/autobahn/avail/inner.go index 721febc42c..dd8b0fe391 100644 --- a/sei-tendermint/internal/autobahn/avail/inner.go +++ b/sei-tendermint/internal/autobahn/avail/inner.go @@ -13,9 +13,9 @@ import ( type inner struct { persistedCommitQC utils.AtomicSend[utils.Option[*types.CommitQC]] // latest persisted CommitQC // consensusSpec.Epoch is the applied (next-CommitQC) epoch. advanceEpoch is - // the sole writer after construction. blockVotes are weighted under it. - // CommitQC may lag that epoch while withheld at LastRoad (durable tip is - // persistedCommitQC). + // the sole writer after construction. blockVotes are weighted under this + // epoch at all times. CommitQC may lag that epoch while withheld at LastRoad + // (durable tip is persistedCommitQC). consensusSpec utils.AtomicSend[types.ConsensusSpec] roads *queue[types.RoadIndex, *road] From 85e38ac05540a409aa71599c12b3f1378e9b806e Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 19 Aug 2026 22:39:38 -0700 Subject: [PATCH 19/19] docs(autobahn): Anchor.Epoch is the epoch of CommitQC Drop the admit-stash wording; callers only need what the field is. Co-authored-by: Cursor --- sei-tendermint/internal/autobahn/data/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index c803998589..3b305be16c 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -764,7 +764,7 @@ func (s *State) AppQC(ctx context.Context, n types.GlobalBlockNumber) (*types.Ap type Anchor struct { CommitQC *types.CommitQC AppQC *types.AppQC - // Epoch of CommitQC, stashed at admit. + // Epoch of CommitQC. Epoch *types.Epoch }