Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions sei-tendermint/autobahn/types/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why did you drop it?

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)))
Expand Down
4 changes: 1 addition & 3 deletions sei-tendermint/autobahn/types/epoch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions sei-tendermint/autobahn/types/epoch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
27 changes: 24 additions & 3 deletions sei-tendermint/autobahn/types/lane_proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please keep it as "Verify()", to have a way of checking full internal integrity. The fact that it boils down to payload consistency check is an implementation detail.

Excluding the membership check is ok imo, since lane no longer belongs to a single committee/epoch.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: with a name this verbose, one can as well just call VerifyPayload() and VerifySignature separately without loss of readability

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.
Expand Down
9 changes: 7 additions & 2 deletions sei-tendermint/autobahn/types/msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having both "VerifySig" vs "VerifySignature" is a poor naming scheme.
You can remove membership check from VerifySig.

// 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.
Expand Down
8 changes: 8 additions & 0 deletions sei-tendermint/autobahn/types/proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,14 @@ func (v View) Next() View {
return v
}

// ConsensusSpec is the durable CommitQC tip paired with the epoch of the view

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not "view", RoadIndex

// that follows it. Avail publishes Option[ConsensusSpec] (None until a tip
// exists); consensus installs Some values verbatim.
type ConsensusSpec struct {
CommitQC *CommitQC

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option[CommitQC]? ConsensusSpec for index 0 won't have a 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.
Expand Down
2 changes: 1 addition & 1 deletion sei-tendermint/autobahn/types/testonly.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
65 changes: 48 additions & 17 deletions sei-tendermint/internal/autobahn/avail/block_votes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]()
}
141 changes: 141 additions & 0 deletions sei-tendermint/internal/autobahn/avail/block_votes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
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{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we test a reweighting of weights here for a single validator to test reweight logic?

This is what was suggested:

 A useful concrete test arrangement would be:

  - Invalidation case:
      - Epoch 0: A=3, B=1, C=1, D=1; an A vote alone reaches lane quorum.
      - Epoch 1: A=1, B=1, C=5, D=5; everyone stays, but A alone no longer reaches quorum.
      - After reweight, bv.qc must be empty.

  - Formation case:
      - Store votes from A and B while their combined weight is insufficient.
      - Increase their weights without changing membership.
      - After reweight, the retained votes should form a QC.

  This is non-blocking test coverage unless there is evidence the current implementation fails it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

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))
}

// 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())
}
Loading
Loading