From 7e926fba5343cb575a27127aad31721e4c72b7bd Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 28 Apr 2026 17:02:19 +0200 Subject: [PATCH 1/9] feat(network): load fixed attester set from genesis --- modules/network/genesis.go | 110 ++++--- modules/network/keeper/bitmap_test.go | 269 ++++++++++++++++++ modules/network/keeper/genesis_test.go | 131 +++++++++ modules/network/keeper/keeper.go | 8 +- modules/network/keeper/msg_server.go | 80 +----- modules/network/keeper/msg_server_test.go | 192 ++----------- modules/network/types/attester.go | 40 +++ modules/network/types/attester.pb.go | 93 ++++-- modules/network/types/genesis.go | 14 + modules/network/types/genesis.pb.go | 106 +++++-- .../proto/evabci/network/v1/attester.proto | 5 + modules/proto/evabci/network/v1/genesis.proto | 5 + 12 files changed, 717 insertions(+), 336 deletions(-) create mode 100644 modules/network/keeper/bitmap_test.go create mode 100644 modules/network/keeper/genesis_test.go create mode 100644 modules/network/types/attester.go diff --git a/modules/network/genesis.go b/modules/network/genesis.go index 85e0f6da..388b3a03 100644 --- a/modules/network/genesis.go +++ b/modules/network/genesis.go @@ -1,9 +1,12 @@ package network import ( + "bytes" "fmt" + "sort" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/bech32" "github.com/evstack/ev-abci/modules/network/keeper" "github.com/evstack/ev-abci/modules/network/types" @@ -11,38 +14,80 @@ import ( // InitGenesis initializes the network module's state from a provided genesis state. func InitGenesis(ctx sdk.Context, k keeper.Keeper, genState types.GenesisState) error { - // Set module params if err := k.SetParams(ctx, genState.Params); err != nil { return fmt.Errorf("set params: %s", err) } - // Set validator indices - for _, vi := range genState.ValidatorIndices { - if err := k.SetValidatorIndex(ctx, vi.Address, uint16(vi.Index), vi.Power); err != nil { - return err + // Load attesters: validate pubkey/address match, then insert and assign indices. + attesters := make([]types.AttesterInfo, len(genState.AttesterInfos)) + copy(attesters, genState.AttesterInfos) + + for i := range attesters { + info := attesters[i] + pk, err := info.GetPubKey() + if err != nil { + return fmt.Errorf("attester %d: %w", i, err) } - // Also add to attester set - if err := k.SetAttesterSetMember(ctx, vi.Address); err != nil { - return err + // Validate pubkey ↔ consensus_address match at the raw-bytes level so + // the check is independent of bech32 prefix (e.g. "cosmosvalcons" vs + // "celestiavalcons"). Whatever prefix was used in genesis, the 20-byte + // payload must equal the pubkey's Address(). + _, rawAddr, decErr := bech32.DecodeAndConvert(info.ConsensusAddress) + if decErr != nil { + return fmt.Errorf("attester %d: decode consensus_address %q: %w", i, info.ConsensusAddress, decErr) + } + if !bytes.Equal(rawAddr, pk.Address()) { + return fmt.Errorf("attester %d: pubkey address mismatch (derived bytes %x, stated bytes %x)", + i, pk.Address(), rawAddr) + } + // Re-encode consensus_address with the runtime SDK config so the + // stored value matches what ConsAddress().String() produces elsewhere + // in the module at runtime. + derived := sdk.ConsAddress(pk.Address()).String() + info.ConsensusAddress = derived + attesters[i] = info + } + + // Order by pubkey.Address() bytes ascending to match cmttypes.NewValidatorSet. + sort.Slice(attesters, func(i, j int) bool { + pki, _ := attesters[i].GetPubKey() + pkj, _ := attesters[j].GetPubKey() + return bytes.Compare(pki.Address(), pkj.Address()) < 0 + }) + + for idx, info := range attesters { + if err := k.SetAttesterInfo(ctx, info.ConsensusAddress, &info); err != nil { + return fmt.Errorf("set attester info: %w", err) + } + if err := k.SetAttesterSetMember(ctx, info.ConsensusAddress); err != nil { + return fmt.Errorf("set attester set member: %w", err) + } + if err := k.SetValidatorIndex(ctx, info.ConsensusAddress, uint16(idx), 1); err != nil { + return fmt.Errorf("set validator index: %w", err) } } - // Set attestation bitmaps + // Still load historical bitmaps if provided (upgrade/dump scenarios). for _, ab := range genState.AttestationBitmaps { if err := k.SetAttestationBitmap(ctx, ab.Height, ab.Bitmap); err != nil { return err } - // Store full attestation info using collections API if err := k.StoredAttestationInfo.Set(ctx, ab.Height, ab); err != nil { return err } - if ab.SoftConfirmed { if err := setSoftConfirmed(ctx, k, ab.Height); err != nil { return err } } } + + // Legacy: genState.ValidatorIndices is now derived from AttesterInfos and + // ignored. Warn if non-empty so operators notice. + if len(genState.ValidatorIndices) > 0 { + k.Logger(ctx).Error("genesis.validator_indices is deprecated and ignored; use attester_infos") + } + return nil } @@ -51,29 +96,22 @@ func ExportGenesis(ctx sdk.Context, k keeper.Keeper) *types.GenesisState { genesis := types.DefaultGenesisState() genesis.Params = k.GetParams(ctx) - // Export validator indices using collections API - var validatorIndices []types.ValidatorIndex - // Iterate through all validator indices - if err := k.ValidatorIndex.Walk(ctx, nil, func(addr string, index uint16) (bool, error) { - power, err := k.GetValidatorPower(ctx, index) - if err != nil { - return false, fmt.Errorf("get validator power: %w", err) - } - validatorIndices = append(validatorIndices, types.ValidatorIndex{ - Address: addr, - Index: uint32(index), - Power: power, - }) + var attesters []types.AttesterInfo + if err := k.AttesterInfo.Walk(ctx, nil, func(_ string, info types.AttesterInfo) (bool, error) { + attesters = append(attesters, info) return false, nil }); err != nil { panic(err) } - genesis.ValidatorIndices = validatorIndices + sort.Slice(attesters, func(i, j int) bool { + pki, _ := attesters[i].GetPubKey() + pkj, _ := attesters[j].GetPubKey() + return bytes.Compare(pki.Address(), pkj.Address()) < 0 + }) + genesis.AttesterInfos = attesters - // Export attestation bitmaps using collections API var attestationBitmaps []types.AttestationBitmap - // Iterate through all stored attestation info - if err := k.StoredAttestationInfo.Walk(ctx, nil, func(height int64, ab types.AttestationBitmap) (bool, error) { + if err := k.StoredAttestationInfo.Walk(ctx, nil, func(_ int64, ab types.AttestationBitmap) (bool, error) { attestationBitmaps = append(attestationBitmaps, ab) return false, nil }); err != nil { @@ -81,24 +119,18 @@ func ExportGenesis(ctx sdk.Context, k keeper.Keeper) *types.GenesisState { } genesis.AttestationBitmaps = attestationBitmaps + // ValidatorIndices no longer exported: they are derived deterministically + // from AttesterInfos order. + genesis.ValidatorIndices = nil + return genesis } -// Helper function to set soft confirmed status func setSoftConfirmed(ctx sdk.Context, k keeper.Keeper, height int64) error { - // Get the existing attestation bitmap ab, err := k.StoredAttestationInfo.Get(ctx, height) if err != nil { - // If there's no existing attestation bitmap, we can't set it as soft confirmed return err } - - // Set the SoftConfirmed field to true ab.SoftConfirmed = true - - // Update the attestation bitmap in the collection - if err := k.StoredAttestationInfo.Set(ctx, height, ab); err != nil { - return err - } - return nil + return k.StoredAttestationInfo.Set(ctx, height, ab) } diff --git a/modules/network/keeper/bitmap_test.go b/modules/network/keeper/bitmap_test.go new file mode 100644 index 00000000..9515f9a4 --- /dev/null +++ b/modules/network/keeper/bitmap_test.go @@ -0,0 +1,269 @@ +package keeper + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewBitmap(t *testing.T) { + bh := NewBitmapHelper() + + specs := map[string]struct { + n int + expSize int + }{ + "zero validators": {n: 0, expSize: 0}, + "one validator": {n: 1, expSize: 1}, + "seven validators": {n: 7, expSize: 1}, + "eight validators": {n: 8, expSize: 1}, + "nine validators": {n: 9, expSize: 2}, + "sixteen validators": {n: 16, expSize: 2}, + "seventeen validators": {n: 17, expSize: 3}, + "one-hundred validators": {n: 100, expSize: 13}, + "ten-thousand validators": {n: 10_000, expSize: 1250}, + } + + for name, spec := range specs { + t.Run(name, func(t *testing.T) { + bitmap := bh.NewBitmap(spec.n) + require.Len(t, bitmap, spec.expSize) + for _, b := range bitmap { + assert.Equal(t, byte(0), b, "new bitmap must be zero-initialised") + } + }) + } +} + +func TestSetBitAndIsSet(t *testing.T) { + bh := NewBitmapHelper() + + t.Run("set and read within range", func(t *testing.T) { + bitmap := bh.NewBitmap(16) + // Cover low-byte, high-byte, and edges. + for _, idx := range []int{0, 1, 7, 8, 9, 15} { + assert.False(t, bh.IsSet(bitmap, idx), "bit %d should start unset", idx) + bh.SetBit(bitmap, idx) + assert.True(t, bh.IsSet(bitmap, idx), "bit %d should be set", idx) + } + }) + + t.Run("out-of-range index is a no-op", func(t *testing.T) { + bitmap := bh.NewBitmap(8) + + bh.SetBit(bitmap, -1) + bh.SetBit(bitmap, 8) + bh.SetBit(bitmap, 100) + + assert.Equal(t, 0, bh.PopCount(bitmap), "out-of-range SetBit must not flip any bit") + assert.False(t, bh.IsSet(bitmap, -1)) + assert.False(t, bh.IsSet(bitmap, 8)) + assert.False(t, bh.IsSet(bitmap, 100)) + }) + + t.Run("set is idempotent", func(t *testing.T) { + bitmap := bh.NewBitmap(8) + bh.SetBit(bitmap, 3) + bh.SetBit(bitmap, 3) + assert.Equal(t, 1, bh.PopCount(bitmap)) + assert.True(t, bh.IsSet(bitmap, 3)) + }) + + t.Run("setting one bit does not affect neighbours", func(t *testing.T) { + bitmap := bh.NewBitmap(16) + bh.SetBit(bitmap, 4) + for i := 0; i < 16; i++ { + if i == 4 { + continue + } + assert.False(t, bh.IsSet(bitmap, i), "bit %d must remain unset", i) + } + }) +} + +func TestPopCount(t *testing.T) { + bh := NewBitmapHelper() + + specs := map[string]struct { + bitmap []byte + expCount int + }{ + "empty": {bitmap: []byte{}, expCount: 0}, + "nil": {bitmap: nil, expCount: 0}, + "all zeros": {bitmap: []byte{0x00, 0x00}, expCount: 0}, + "all ones one byte": {bitmap: []byte{0xFF}, expCount: 8}, + "all ones two bytes": {bitmap: []byte{0xFF, 0xFF}, expCount: 16}, + "single bit low": {bitmap: []byte{0x01}, expCount: 1}, + "single bit high": {bitmap: []byte{0x80}, expCount: 1}, + "mixed": {bitmap: []byte{0x0F, 0xF0}, expCount: 8}, + } + + for name, spec := range specs { + t.Run(name, func(t *testing.T) { + assert.Equal(t, spec.expCount, bh.PopCount(spec.bitmap)) + }) + } +} + +func TestOR(t *testing.T) { + bh := NewBitmapHelper() + + t.Run("same length", func(t *testing.T) { + dst := []byte{0x0F, 0x00} + src := []byte{0xF0, 0xAA} + bh.OR(dst, src) + assert.Equal(t, []byte{0xFF, 0xAA}, dst) + }) + + t.Run("src shorter than dst leaves extra bytes untouched", func(t *testing.T) { + dst := []byte{0x00, 0x00, 0xAA} + src := []byte{0x0F} + bh.OR(dst, src) + assert.Equal(t, []byte{0x0F, 0x00, 0xAA}, dst) + }) + + t.Run("dst shorter than src ignores extra src bytes", func(t *testing.T) { + dst := []byte{0x00} + src := []byte{0x0F, 0xFF} + bh.OR(dst, src) + assert.Equal(t, []byte{0x0F}, dst) + }) + + t.Run("OR is commutative on overlap", func(t *testing.T) { + a := []byte{0b11001100} + b := []byte{0b10101010} + expected := []byte{0b11101110} + + cp1 := append([]byte(nil), a...) + bh.OR(cp1, b) + cp2 := append([]byte(nil), b...) + bh.OR(cp2, a) + + assert.Equal(t, expected, cp1) + assert.Equal(t, expected, cp2) + }) +} + +func TestAND(t *testing.T) { + bh := NewBitmapHelper() + + t.Run("same length", func(t *testing.T) { + dst := []byte{0xFF, 0xAA} + src := []byte{0x0F, 0xF0} + bh.AND(dst, src) + assert.Equal(t, []byte{0x0F, 0xA0}, dst) + }) + + t.Run("src shorter than dst leaves extra bytes untouched", func(t *testing.T) { + dst := []byte{0xFF, 0xFF, 0xAA} + src := []byte{0x0F} + bh.AND(dst, src) + assert.Equal(t, []byte{0x0F, 0xFF, 0xAA}, dst) + }) + + t.Run("disjoint bits produce zero", func(t *testing.T) { + dst := []byte{0xF0} + src := []byte{0x0F} + bh.AND(dst, src) + assert.Equal(t, []byte{0x00}, dst) + }) +} + +func TestCopy(t *testing.T) { + bh := NewBitmapHelper() + + t.Run("nil input returns nil", func(t *testing.T) { + assert.Nil(t, bh.Copy(nil)) + }) + + t.Run("empty input returns empty non-nil slice", func(t *testing.T) { + cp := bh.Copy([]byte{}) + require.NotNil(t, cp) + assert.Len(t, cp, 0) + }) + + t.Run("copy is independent of original", func(t *testing.T) { + original := []byte{0x0F, 0xF0} + cp := bh.Copy(original) + assert.Equal(t, original, cp) + + cp[0] = 0xAA + assert.Equal(t, byte(0x0F), original[0], "mutating copy must not affect original") + original[1] = 0xBB + assert.Equal(t, byte(0xF0), cp[1], "mutating original must not affect copy") + }) +} + +func TestClear(t *testing.T) { + bh := NewBitmapHelper() + + t.Run("clears non-empty bitmap", func(t *testing.T) { + bitmap := []byte{0xFF, 0xAA, 0x55} + bh.Clear(bitmap) + assert.Equal(t, []byte{0x00, 0x00, 0x00}, bitmap) + }) + + t.Run("clear on empty bitmap is a no-op", func(t *testing.T) { + bitmap := []byte{} + bh.Clear(bitmap) + assert.Equal(t, []byte{}, bitmap) + }) + + t.Run("clear on nil is a no-op", func(t *testing.T) { + var bitmap []byte + bh.Clear(bitmap) + assert.Nil(t, bitmap) + }) +} + +func TestCountInRange(t *testing.T) { + bh := NewBitmapHelper() + + // bitmap of 16 bits with bits 1, 3, 5, 8, 15 set + bitmap := bh.NewBitmap(16) + for _, idx := range []int{1, 3, 5, 8, 15} { + bh.SetBit(bitmap, idx) + } + + specs := map[string]struct { + start, end int + expCount int + }{ + "full range": {start: 0, end: 16, expCount: 5}, + "first byte only": {start: 0, end: 8, expCount: 3}, + "second byte only": {start: 8, end: 16, expCount: 2}, + "empty range": {start: 4, end: 4, expCount: 0}, + "inverted range is empty": {start: 10, end: 4, expCount: 0}, + "end past bitmap length": {start: 0, end: 1000, expCount: 5}, + "start past bitmap length": {start: 200, end: 300, expCount: 0}, + "range excluding boundary bit": {start: 2, end: 5, expCount: 1}, + "range including boundary bit": {start: 1, end: 6, expCount: 3}, + } + + for name, spec := range specs { + t.Run(name, func(t *testing.T) { + assert.Equal(t, spec.expCount, bh.CountInRange(bitmap, spec.start, spec.end)) + }) + } +} + +func TestBitmapRoundTrip(t *testing.T) { + bh := NewBitmapHelper() + + indices := []int{0, 3, 7, 8, 15, 63, 99} + bitmap := bh.NewBitmap(100) + for _, idx := range indices { + bh.SetBit(bitmap, idx) + } + + assert.Equal(t, len(indices), bh.PopCount(bitmap)) + for _, idx := range indices { + assert.True(t, bh.IsSet(bitmap, idx), "bit %d should be set", idx) + } + + cp := bh.Copy(bitmap) + bh.Clear(bitmap) + assert.Equal(t, 0, bh.PopCount(bitmap)) + assert.Equal(t, len(indices), bh.PopCount(cp), "copy must be unaffected by clearing original") +} diff --git a/modules/network/keeper/genesis_test.go b/modules/network/keeper/genesis_test.go new file mode 100644 index 00000000..3acdbd06 --- /dev/null +++ b/modules/network/keeper/genesis_test.go @@ -0,0 +1,131 @@ +package keeper_test + +import ( + "bytes" + "sort" + "testing" + "time" + + "cosmossdk.io/log" + storetypes "cosmossdk.io/store/types" + cmted25519 "github.com/cometbft/cometbft/crypto/ed25519" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + "github.com/cosmos/cosmos-sdk/codec" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + "github.com/cosmos/cosmos-sdk/runtime" + "github.com/cosmos/cosmos-sdk/testutil/integration" + sdk "github.com/cosmos/cosmos-sdk/types" + moduletestutil "github.com/cosmos/cosmos-sdk/types/module/testutil" + authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-abci/modules/network" + "github.com/evstack/ev-abci/modules/network/keeper" + "github.com/evstack/ev-abci/modules/network/types" +) + +func newKeeperForGenesis(t *testing.T) (keeper.Keeper, sdk.Context, codec.BinaryCodec) { + t.Helper() + cdc := moduletestutil.MakeTestEncodingConfig().Codec + keys := storetypes.NewKVStoreKeys(types.StoreKey) + logger := log.NewTestLogger(t) + cms := integration.CreateMultiStore(keys, logger) + authority := authtypes.NewModuleAddress("gov") + k := keeper.NewKeeper(cdc, runtime.NewKVStoreService(keys[types.StoreKey]), nil, nil, nil, authority.String()) + ctx := sdk.NewContext(cms, cmtproto.Header{ChainID: "test-chain", Time: time.Now().UTC(), Height: 1}, false, logger). + WithContext(t.Context()) + return k, ctx, cdc +} + +func mustAnyPubKey(t *testing.T, cmtPk cmted25519.PubKey) *types.AttesterInfo { + t.Helper() + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(cmtPk) + require.NoError(t, err) + info, err := types.NewAttesterInfo( + sdk.AccAddress(cmtPk.Address()).String(), + sdkPk, + 0, + ) + require.NoError(t, err) + return info +} + +func TestInitGenesisLoadsAttesters(t *testing.T) { + k, ctx, _ := newKeeperForGenesis(t) + + pk1 := cmted25519.GenPrivKey().PubKey().(cmted25519.PubKey) + pk2 := cmted25519.GenPrivKey().PubKey().(cmted25519.PubKey) + info1 := mustAnyPubKey(t, pk1) + info1.ConsensusAddress = sdk.ConsAddress(pk1.Address()).String() + info2 := mustAnyPubKey(t, pk2) + info2.ConsensusAddress = sdk.ConsAddress(pk2.Address()).String() + + gs := types.GenesisState{ + Params: types.DefaultParams(), + AttesterInfos: []types.AttesterInfo{*info1, *info2}, + } + + require.NoError(t, network.InitGenesis(ctx, k, gs)) + + // Both in AttesterSet + for _, info := range []*types.AttesterInfo{info1, info2} { + has, err := k.AttesterSet.Has(ctx, info.ConsensusAddress) + require.NoError(t, err) + require.True(t, has) + + stored, err := k.GetAttesterInfo(ctx, info.ConsensusAddress) + require.NoError(t, err) + require.Equal(t, info.Authority, stored.Authority) + } + + // Validator indices assigned in ascending pubkey-address order, power=1 + expectedOrder := []cmted25519.PubKey{pk1, pk2} + sort.Slice(expectedOrder, func(i, j int) bool { + return bytes.Compare(expectedOrder[i].Address(), expectedOrder[j].Address()) < 0 + }) + for i, pk := range expectedOrder { + consAddr := sdk.ConsAddress(pk.Address()).String() + idx, found := k.GetValidatorIndex(ctx, consAddr) + require.True(t, found, "consensus address %s missing index", consAddr) + require.Equal(t, uint16(i), idx) + power, err := k.GetValidatorPower(ctx, idx) + require.NoError(t, err) + require.Equal(t, uint64(1), power) + } +} + +func TestInitGenesisRejectsPubkeyAddressMismatch(t *testing.T) { + k, ctx, _ := newKeeperForGenesis(t) + + pk := cmted25519.GenPrivKey().PubKey().(cmted25519.PubKey) + info := mustAnyPubKey(t, pk) + info.ConsensusAddress = sdk.ConsAddress([]byte("not-matching-20-bytes")).String() + + gs := types.GenesisState{ + Params: types.DefaultParams(), + AttesterInfos: []types.AttesterInfo{*info}, + } + + err := network.InitGenesis(ctx, k, gs) + require.Error(t, err) + require.Contains(t, err.Error(), "pubkey address mismatch") +} + +func TestExportGenesisRoundtripsAttesters(t *testing.T) { + k, ctx, _ := newKeeperForGenesis(t) + + pk := cmted25519.GenPrivKey().PubKey().(cmted25519.PubKey) + info := mustAnyPubKey(t, pk) + info.ConsensusAddress = sdk.ConsAddress(pk.Address()).String() + + gs := types.GenesisState{ + Params: types.DefaultParams(), + AttesterInfos: []types.AttesterInfo{*info}, + } + require.NoError(t, network.InitGenesis(ctx, k, gs)) + + exported := network.ExportGenesis(ctx, k) + require.Len(t, exported.AttesterInfos, 1) + require.Equal(t, info.ConsensusAddress, exported.AttesterInfos[0].ConsensusAddress) + require.Equal(t, info.Authority, exported.AttesterInfos[0].Authority) +} diff --git a/modules/network/keeper/keeper.go b/modules/network/keeper/keeper.go index 60494d03..df3d8c50 100644 --- a/modules/network/keeper/keeper.go +++ b/modules/network/keeper/keeper.go @@ -185,11 +185,6 @@ func (k Keeper) GetAllAttesters(ctx sdk.Context) ([]string, error) { return attesters, nil } -// MaxAttesters is the maximum number of attesters allowed in the set. -// This prevents unbounded growth, EndBlocker stalling, and uint16 index overflow -// in BuildValidatorIndexMap. -const MaxAttesters = 10_000 - // BuildValidatorIndexMap rebuilds the validator index mapping func (k Keeper) BuildValidatorIndexMap(ctx sdk.Context) error { // Get all attesters instead of bonded validators @@ -198,8 +193,7 @@ func (k Keeper) BuildValidatorIndexMap(ctx sdk.Context) error { return err } - // Guard against uint16 overflow — should not happen if MaxAttesters is enforced - // at join time, but defense-in-depth + // Guard against uint16 overflow (defense-in-depth). if len(attesters) > int(^uint16(0)) { return fmt.Errorf("attester count %d exceeds uint16 max %d", len(attesters), ^uint16(0)) } diff --git a/modules/network/keeper/msg_server.go b/modules/network/keeper/msg_server.go index 2bf7034e..17143579 100644 --- a/modules/network/keeper/msg_server.go +++ b/modules/network/keeper/msg_server.go @@ -161,86 +161,14 @@ func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.M // JoinAttesterSet handles MsgJoinAttesterSet func (k msgServer) JoinAttesterSet(goCtx context.Context, msg *types.MsgJoinAttesterSet) (*types.MsgJoinAttesterSetResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - // Validate the consensus address format - _, err := sdk.ValAddressFromBech32(msg.ConsensusAddress) - if err != nil { - return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidAddress, "invalid consensus address: %s", err) - } - - // NOTE: Removed bonded validator requirement to allow any address to join attester set - // This allows external attesters that are not part of the validator set - - // Check if already in attester set (use consensus address) - has, err := k.IsInAttesterSet(ctx, msg.ConsensusAddress) - if err != nil { - return nil, sdkerr.Wrapf(err, "in attester set") - } - if has { - return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "consensus address already in attester set") - } - - // Enforce maximum attester set size to prevent unbounded growth and uint16 index overflow - attesters, err := k.GetAllAttesters(ctx) - if err != nil { - return nil, sdkerr.Wrap(err, "get all attesters") - } - if len(attesters) >= MaxAttesters { - return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "attester set is full: %d/%d", len(attesters), MaxAttesters) - } - - // Store the attester information including pubkey (key by consensus address) - attesterInfo := &types.AttesterInfo{ - Authority: msg.Authority, - Pubkey: msg.Pubkey, - JoinedHeight: ctx.BlockHeight(), - } - - if err := k.SetAttesterInfo(ctx, msg.ConsensusAddress, attesterInfo); err != nil { - return nil, sdkerr.Wrap(err, "set attester info") - } - // TODO (Alex): the valset should be updated at the end of an epoch only - if err := k.SetAttesterSetMember(ctx, msg.ConsensusAddress); err != nil { - return nil, sdkerr.Wrap(err, "set attester set member") - } - - ctx.EventManager().EmitEvent( - sdk.NewEvent( - types.TypeMsgJoinAttesterSet, - sdk.NewAttribute("consensus_address", msg.ConsensusAddress), - sdk.NewAttribute("authority", msg.Authority), - ), - ) - k.Logger(ctx).Info("+++ joined attester set", "consensus_address", msg.ConsensusAddress, "authority", msg.Authority) - return &types.MsgJoinAttesterSetResponse{}, nil + return nil, sdkerr.Wrap(sdkerrors.ErrInvalidRequest, + "attester set changes disabled; the set is fixed at genesis") } // LeaveAttesterSet handles MsgLeaveAttesterSet func (k msgServer) LeaveAttesterSet(goCtx context.Context, msg *types.MsgLeaveAttesterSet) (*types.MsgLeaveAttesterSetResponse, error) { - ctx := sdk.UnwrapSDKContext(goCtx) - - if err := k.assertValidValidatorAuthority(ctx, msg.ConsensusAddress, msg.Authority); err != nil { - return nil, err - } - - if err := k.AttesterInfo.Remove(ctx, msg.ConsensusAddress); err != nil { - return nil, sdkerr.Wrap(err, "remove attester info") - } - // TODO (Alex): the valset should be updated at the end of an epoch only - if err := k.RemoveAttesterSetMember(ctx, msg.ConsensusAddress); err != nil { - return nil, sdkerr.Wrap(err, "remove attester set member") - } - - ctx.EventManager().EmitEvent( - sdk.NewEvent( - types.TypeMsgLeaveAttesterSet, - sdk.NewAttribute("consensus_address", msg.ConsensusAddress), - sdk.NewAttribute("authority", msg.Authority), - ), - ) - - return &types.MsgLeaveAttesterSetResponse{}, nil + return nil, sdkerr.Wrap(sdkerrors.ErrInvalidRequest, + "attester set changes disabled; the set is fixed at genesis") } func (k msgServer) assertValidValidatorAuthority(ctx sdk.Context, consensusAddress, authority string) error { diff --git a/modules/network/keeper/msg_server_test.go b/modules/network/keeper/msg_server_test.go index a4419612..02c648ba 100644 --- a/modules/network/keeper/msg_server_test.go +++ b/modules/network/keeper/msg_server_test.go @@ -26,106 +26,31 @@ import ( "github.com/evstack/ev-abci/modules/network/types" ) -func TestJoinAttesterSet(t *testing.T) { - myValAddr := sdk.ValAddress("validator4") +func TestJoinAttesterSetDisabled(t *testing.T) { + sk := NewMockStakingKeeper() + server, _, ctx := newTestServer(t, &sk) - type testCase struct { - setup func(t *testing.T, ctx sdk.Context, keeper *Keeper, sk *MockStakingKeeper) - msg *types.MsgJoinAttesterSet - expErr error - expSet bool - } - - tests := map[string]testCase{ - "valid": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, sk *MockStakingKeeper) { - validator := stakingtypes.Validator{ - OperatorAddress: myValAddr.String(), - Status: stakingtypes.Bonded, - } - err := sk.SetValidator(ctx, validator) - require.NoError(t, err, "failed to set validator") - }, - msg: &types.MsgJoinAttesterSet{Authority: myValAddr.String(), ConsensusAddress: myValAddr.String()}, - expSet: true, - }, - "invalid_addr": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, sk *MockStakingKeeper) {}, - msg: &types.MsgJoinAttesterSet{Authority: "invalidAddr", ConsensusAddress: "invalidAddr"}, - expErr: sdkerrors.ErrInvalidAddress, - }, - "already set": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, sk *MockStakingKeeper) { - validator := stakingtypes.Validator{ - OperatorAddress: myValAddr.String(), - Status: stakingtypes.Bonded, - } - require.NoError(t, sk.SetValidator(ctx, validator)) - require.NoError(t, keeper.SetAttesterSetMember(ctx, myValAddr.String())) - }, - msg: &types.MsgJoinAttesterSet{Authority: myValAddr.String(), ConsensusAddress: myValAddr.String()}, - expErr: sdkerrors.ErrInvalidRequest, - expSet: true, - }, - } - - for name, spec := range tests { - t.Run(name, func(t *testing.T) { - sk := NewMockStakingKeeper() - server, keeper, ctx := newTestServer(t, &sk) - - spec.setup(t, ctx, &keeper, &sk) - - // when - rsp, err := server.JoinAttesterSet(ctx, spec.msg) - // then - if spec.expErr != nil { - require.ErrorIs(t, err, spec.expErr) - require.Nil(t, rsp) - exists, gotErr := keeper.AttesterSet.Has(ctx, spec.msg.ConsensusAddress) - require.NoError(t, gotErr) - assert.Equal(t, exists, spec.expSet) - return - } - require.NoError(t, err) - require.NotNil(t, rsp) - exists, gotErr := keeper.AttesterSet.Has(ctx, spec.msg.ConsensusAddress) - require.NoError(t, gotErr) - assert.True(t, exists) - - // Verify authority is stored correctly in AttesterInfo - info, infoErr := keeper.GetAttesterInfo(ctx, spec.msg.ConsensusAddress) - require.NoError(t, infoErr) - assert.Equal(t, spec.msg.Authority, info.Authority) - }) + msg := &types.MsgJoinAttesterSet{ + Authority: sdk.AccAddress([]byte("any-authority-20b")).String(), + ConsensusAddress: sdk.ConsAddress([]byte("any-cons-addr-20-b")).String(), } + rsp, err := server.JoinAttesterSet(ctx, msg) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + require.Contains(t, err.Error(), "attester set changes disabled") + require.Nil(t, rsp) } -func TestJoinAttesterSetMaxCap(t *testing.T) { - // Verify the constant is set to a sane value that is within uint16 range - require.LessOrEqual(t, MaxAttesters, int(^uint16(0)), - "MaxAttesters must fit in uint16 to avoid index overflow in BuildValidatorIndexMap") - - t.Run("join succeeds under cap", func(t *testing.T) { - sk := NewMockStakingKeeper() - server, keeper, ctx := newTestServer(t, &sk) +func TestLeaveAttesterSetDisabled(t *testing.T) { + sk := NewMockStakingKeeper() + server, _, ctx := newTestServer(t, &sk) - // With an empty set, join should succeed - newAddr := sdk.ValAddress("new_attester") - msg := &types.MsgJoinAttesterSet{ - Authority: newAddr.String(), - ConsensusAddress: newAddr.String(), - } - - rsp, err := server.JoinAttesterSet(ctx, msg) - require.NoError(t, err) - require.NotNil(t, rsp) - - // Verify the attester was added - exists, err := keeper.AttesterSet.Has(ctx, newAddr.String()) - require.NoError(t, err) - assert.True(t, exists) - }) + msg := &types.MsgLeaveAttesterSet{ + Authority: sdk.AccAddress([]byte("any-authority-20b")).String(), + ConsensusAddress: sdk.ConsAddress([]byte("any-cons-addr-20-b")).String(), + } + rsp, err := server.LeaveAttesterSet(ctx, msg) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + require.Nil(t, rsp) } func TestAttestVotePayloadValidation(t *testing.T) { @@ -183,83 +108,6 @@ func TestAttestVotePayloadValidation(t *testing.T) { } } -func TestLeaveAttesterSet(t *testing.T) { - ownerAddr := sdk.ValAddress("owner1") - otherAddr := sdk.ValAddress("other1") - - type testCase struct { - setup func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) - msg *types.MsgLeaveAttesterSet - expErr error - } - - tests := map[string]testCase{ - "valid": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) { - t.Helper() - joinMsg := &types.MsgJoinAttesterSet{ - Authority: ownerAddr.String(), - ConsensusAddress: ownerAddr.String(), - } - _, err := server.JoinAttesterSet(ctx, joinMsg) - require.NoError(t, err) - }, - msg: &types.MsgLeaveAttesterSet{ - Authority: ownerAddr.String(), - ConsensusAddress: ownerAddr.String(), - }, - }, - "not_in_set": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) { - t.Helper() - }, - msg: &types.MsgLeaveAttesterSet{ - Authority: ownerAddr.String(), - ConsensusAddress: ownerAddr.String(), - }, - expErr: sdkerrors.ErrUnauthorized, - }, - "wrong_authority": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) { - t.Helper() - joinMsg := &types.MsgJoinAttesterSet{ - Authority: ownerAddr.String(), - ConsensusAddress: ownerAddr.String(), - } - _, err := server.JoinAttesterSet(ctx, joinMsg) - require.NoError(t, err) - }, - msg: &types.MsgLeaveAttesterSet{ - Authority: otherAddr.String(), - ConsensusAddress: ownerAddr.String(), - }, - expErr: sdkerrors.ErrUnauthorized, - }, - } - - for name, spec := range tests { - t.Run(name, func(t *testing.T) { - sk := NewMockStakingKeeper() - server, keeper, ctx := newTestServer(t, &sk) - - spec.setup(t, ctx, &keeper, server) - - rsp, err := server.LeaveAttesterSet(ctx, spec.msg) - if spec.expErr != nil { - require.ErrorIs(t, err, spec.expErr) - require.Nil(t, rsp) - return - } - require.NoError(t, err) - require.NotNil(t, rsp) - - // Verify actually removed from attester set - exists, gotErr := keeper.AttesterSet.Has(ctx, spec.msg.ConsensusAddress) - require.NoError(t, gotErr) - assert.False(t, exists) - }) - } -} func TestAttest(t *testing.T) { ownerAddr := sdk.ValAddress("attester_owner") diff --git a/modules/network/types/attester.go b/modules/network/types/attester.go new file mode 100644 index 00000000..ebfd544c --- /dev/null +++ b/modules/network/types/attester.go @@ -0,0 +1,40 @@ +package types + +import ( + "fmt" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +func NewAttesterInfo(authority string, pk cryptotypes.PubKey, joinedHeight int64) (*AttesterInfo, error) { + any, err := codectypes.NewAnyWithValue(pk) + if err != nil { + return nil, fmt.Errorf("pack pubkey: %w", err) + } + return &AttesterInfo{ + Authority: authority, + Pubkey: any, + JoinedHeight: joinedHeight, + ConsensusAddress: sdk.ConsAddress(pk.Address()).String(), + }, nil +} + +// GetPubKey extracts the cryptotypes.PubKey from the Any field. +func (a AttesterInfo) GetPubKey() (cryptotypes.PubKey, error) { + if a.Pubkey == nil { + return nil, fmt.Errorf("pubkey not set") + } + pk, ok := a.Pubkey.GetCachedValue().(cryptotypes.PubKey) + if ok { + return pk, nil + } + return nil, fmt.Errorf("pubkey cached value not cryptotypes.PubKey") +} + +// UnpackInterfaces ensures GetCachedValue works after unmarshaling. +func (a AttesterInfo) UnpackInterfaces(unpacker codectypes.AnyUnpacker) error { + var pk cryptotypes.PubKey + return unpacker.UnpackAny(a.Pubkey, &pk) +} diff --git a/modules/network/types/attester.pb.go b/modules/network/types/attester.pb.go index 361f5e93..40896217 100644 --- a/modules/network/types/attester.pb.go +++ b/modules/network/types/attester.pb.go @@ -33,6 +33,10 @@ type AttesterInfo struct { Pubkey *types.Any `protobuf:"bytes,2,opt,name=pubkey,proto3" json:"pubkey,omitempty"` // joined_height is the height at which the attester joined JoinedHeight int64 `protobuf:"varint,3,opt,name=joined_height,json=joinedHeight,proto3" json:"joined_height,omitempty"` + // consensus_address is the bech32 cosmosvalcons1... derived from pubkey. + // Redundant with pubkey but persisted so the keeper's collections key + // (consensus address) matches the stored struct. + ConsensusAddress string `protobuf:"bytes,4,opt,name=consensus_address,json=consensusAddress,proto3" json:"consensus_address,omitempty"` } func (m *AttesterInfo) Reset() { *m = AttesterInfo{} } @@ -75,28 +79,30 @@ func init() { func init() { proto.RegisterFile("evabci/network/v1/attester.proto", fileDescriptor_a8fe3a2e81f284b4) } var fileDescriptor_a8fe3a2e81f284b4 = []byte{ - // 327 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x44, 0x90, 0xbb, 0x4e, 0xc3, 0x30, - 0x14, 0x86, 0x63, 0x2a, 0x55, 0x34, 0x94, 0x81, 0xa8, 0x43, 0xda, 0x21, 0x8d, 0x60, 0xe9, 0x52, - 0x9b, 0x82, 0xc4, 0xc0, 0xd6, 0x0e, 0x88, 0xcb, 0x82, 0xca, 0xc6, 0x52, 0xe5, 0x72, 0x9a, 0x84, - 0xb6, 0x39, 0x91, 0xed, 0x04, 0xf9, 0x0d, 0x18, 0x79, 0x84, 0x3e, 0x04, 0x2b, 0x3b, 0x62, 0xaa, - 0x98, 0x18, 0x51, 0xbb, 0xf0, 0x18, 0x88, 0x38, 0xd0, 0xcd, 0xe7, 0xf3, 0xe7, 0xe3, 0x5f, 0xbf, - 0xe9, 0x42, 0xe1, 0xf9, 0x41, 0xc2, 0x52, 0x90, 0x8f, 0xc8, 0x67, 0xac, 0x18, 0x30, 0x4f, 0x4a, - 0x10, 0x12, 0x38, 0xcd, 0x38, 0x4a, 0xb4, 0x0e, 0xb4, 0x41, 0x2b, 0x83, 0x16, 0x83, 0x4e, 0x3b, - 0x42, 0x8c, 0xe6, 0xc0, 0x4a, 0xc1, 0xcf, 0xa7, 0xcc, 0x4b, 0x95, 0xb6, 0x3b, 0xed, 0x00, 0xc5, - 0x02, 0xc5, 0xa4, 0x9c, 0x98, 0x1e, 0xaa, 0xab, 0x56, 0x84, 0x11, 0x6a, 0xfe, 0x7b, 0xd2, 0xf4, - 0xf0, 0x95, 0x98, 0xcd, 0x61, 0xf5, 0xe3, 0x55, 0x3a, 0x45, 0xeb, 0xcc, 0x6c, 0x78, 0xb9, 0x8c, - 0x91, 0x27, 0x52, 0xd9, 0xc4, 0x25, 0xbd, 0xc6, 0xc8, 0xfe, 0x78, 0xe9, 0xb7, 0xaa, 0x5d, 0xc3, - 0x30, 0xe4, 0x20, 0xc4, 0x9d, 0xe4, 0x49, 0x1a, 0x8d, 0xb7, 0xaa, 0x75, 0x61, 0xd6, 0xb3, 0xdc, - 0x9f, 0x81, 0xb2, 0x77, 0x5c, 0xd2, 0xdb, 0x3b, 0x69, 0x51, 0x9d, 0x92, 0xfe, 0xa5, 0xa4, 0xc3, - 0x54, 0x8d, 0xec, 0xf7, 0xed, 0xaa, 0x80, 0xab, 0x4c, 0x22, 0xbd, 0xcd, 0xfd, 0x1b, 0x50, 0xe3, - 0xea, 0xb5, 0x75, 0x64, 0xee, 0x3f, 0x60, 0x92, 0x42, 0x38, 0x89, 0x21, 0x89, 0x62, 0x69, 0xd7, - 0x5c, 0xd2, 0xab, 0x8d, 0x9b, 0x1a, 0x5e, 0x96, 0xec, 0x7c, 0xf7, 0x69, 0xd9, 0x35, 0xbe, 0x97, - 0x5d, 0x63, 0x74, 0xfd, 0xb6, 0x76, 0xc8, 0x6a, 0xed, 0x90, 0xaf, 0xb5, 0x43, 0x9e, 0x37, 0x8e, - 0xb1, 0xda, 0x38, 0xc6, 0xe7, 0xc6, 0x31, 0xee, 0x8f, 0xa3, 0x44, 0xc6, 0xb9, 0x4f, 0x03, 0x5c, - 0x30, 0x28, 0x84, 0xf4, 0x82, 0x19, 0x83, 0xa2, 0x5f, 0xd6, 0xbd, 0xc0, 0x30, 0x9f, 0x83, 0xf8, - 0xaf, 0x5d, 0xaa, 0x0c, 0x84, 0x5f, 0x2f, 0xa3, 0x9e, 0xfe, 0x04, 0x00, 0x00, 0xff, 0xff, 0xcf, - 0x03, 0xda, 0x4b, 0x95, 0x01, 0x00, 0x00, + // 355 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x84, 0x91, 0xbd, 0x4e, 0xe3, 0x40, + 0x10, 0xc7, 0xed, 0xe4, 0x14, 0x5d, 0x7c, 0x39, 0xe9, 0x62, 0xa5, 0x70, 0x52, 0x38, 0xd6, 0xd1, + 0xa4, 0xc9, 0x2e, 0x01, 0x89, 0x82, 0x2e, 0x91, 0x40, 0x7c, 0x34, 0x28, 0x74, 0x34, 0x91, 0x3f, + 0x26, 0xb6, 0x49, 0xb2, 0x63, 0x79, 0xd7, 0x46, 0xdb, 0x53, 0x50, 0xf2, 0x08, 0x79, 0x08, 0x1e, + 0x02, 0x51, 0x45, 0x54, 0x94, 0x28, 0x69, 0x78, 0x0c, 0x84, 0xd7, 0x24, 0x25, 0xdd, 0xce, 0x6f, + 0x7f, 0x33, 0xfb, 0x5f, 0x8d, 0xe1, 0x40, 0xee, 0x7a, 0x7e, 0x4c, 0x19, 0x88, 0x3b, 0x4c, 0x67, + 0x34, 0x1f, 0x50, 0x57, 0x08, 0xe0, 0x02, 0x52, 0x92, 0xa4, 0x28, 0xd0, 0x6c, 0x2a, 0x83, 0x94, + 0x06, 0xc9, 0x07, 0x9d, 0x76, 0x88, 0x18, 0xce, 0x81, 0x16, 0x82, 0x97, 0x4d, 0xa9, 0xcb, 0xa4, + 0xb2, 0x3b, 0x6d, 0x1f, 0xf9, 0x02, 0xf9, 0xa4, 0xa8, 0xa8, 0x2a, 0xca, 0xab, 0x56, 0x88, 0x21, + 0x2a, 0xfe, 0x75, 0x52, 0xf4, 0xff, 0x7d, 0xc5, 0x68, 0x0c, 0xcb, 0x17, 0xcf, 0xd9, 0x14, 0xcd, + 0x23, 0xa3, 0xee, 0x66, 0x22, 0xc2, 0x34, 0x16, 0xd2, 0xd2, 0x1d, 0xbd, 0x57, 0x1f, 0x59, 0xaf, + 0x4f, 0xfd, 0x56, 0x39, 0x6b, 0x18, 0x04, 0x29, 0x70, 0x7e, 0x2d, 0xd2, 0x98, 0x85, 0xe3, 0x9d, + 0x6a, 0x9e, 0x1a, 0xb5, 0x24, 0xf3, 0x66, 0x20, 0xad, 0x8a, 0xa3, 0xf7, 0xfe, 0x1c, 0xb4, 0x88, + 0x4a, 0x49, 0xbe, 0x53, 0x92, 0x21, 0x93, 0x23, 0xeb, 0x65, 0x37, 0xca, 0x4f, 0x65, 0x22, 0x90, + 0x5c, 0x65, 0xde, 0x25, 0xc8, 0x71, 0xd9, 0x6d, 0xee, 0x19, 0x7f, 0x6f, 0x31, 0x66, 0x10, 0x4c, + 0x22, 0x88, 0xc3, 0x48, 0x58, 0x55, 0x47, 0xef, 0x55, 0xc7, 0x0d, 0x05, 0xcf, 0x0a, 0x66, 0x9e, + 0x18, 0x4d, 0x1f, 0x19, 0x07, 0xc6, 0x33, 0x3e, 0x71, 0x55, 0x24, 0xeb, 0xd7, 0x0f, 0x61, 0xff, + 0x6d, 0x5b, 0x4a, 0x7e, 0xfc, 0xfb, 0x61, 0xd9, 0xd5, 0x3e, 0x96, 0x5d, 0x6d, 0x74, 0xf1, 0xbc, + 0xb6, 0xf5, 0xd5, 0xda, 0xd6, 0xdf, 0xd7, 0xb6, 0xfe, 0xb8, 0xb1, 0xb5, 0xd5, 0xc6, 0xd6, 0xde, + 0x36, 0xb6, 0x76, 0xb3, 0x1f, 0xc6, 0x22, 0xca, 0x3c, 0xe2, 0xe3, 0x82, 0x42, 0xce, 0x85, 0xeb, + 0xcf, 0x28, 0xe4, 0xfd, 0x62, 0x6b, 0x0b, 0x0c, 0xb2, 0x39, 0xf0, 0xed, 0xf6, 0x84, 0x4c, 0x80, + 0x7b, 0xb5, 0xe2, 0xc7, 0x87, 0x9f, 0x01, 0x00, 0x00, 0xff, 0xff, 0x29, 0xc3, 0xde, 0xe5, 0xdc, + 0x01, 0x00, 0x00, } func (m *AttesterInfo) Marshal() (dAtA []byte, err error) { @@ -119,6 +125,13 @@ func (m *AttesterInfo) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + if len(m.ConsensusAddress) > 0 { + i -= len(m.ConsensusAddress) + copy(dAtA[i:], m.ConsensusAddress) + i = encodeVarintAttester(dAtA, i, uint64(len(m.ConsensusAddress))) + i-- + dAtA[i] = 0x22 + } if m.JoinedHeight != 0 { i = encodeVarintAttester(dAtA, i, uint64(m.JoinedHeight)) i-- @@ -174,6 +187,10 @@ func (m *AttesterInfo) Size() (n int) { if m.JoinedHeight != 0 { n += 1 + sovAttester(uint64(m.JoinedHeight)) } + l = len(m.ConsensusAddress) + if l > 0 { + n += 1 + l + sovAttester(uint64(l)) + } return n } @@ -299,6 +316,38 @@ func (m *AttesterInfo) Unmarshal(dAtA []byte) error { break } } + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConsensusAddress", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowAttester + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthAttester + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthAttester + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConsensusAddress = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex default: iNdEx = preIndex skippy, err := skipAttester(dAtA[iNdEx:]) diff --git a/modules/network/types/genesis.go b/modules/network/types/genesis.go index c722e021..3a071e68 100644 --- a/modules/network/types/genesis.go +++ b/modules/network/types/genesis.go @@ -3,8 +3,22 @@ package types import ( "fmt" "math" + + codectypes "github.com/cosmos/cosmos-sdk/codec/types" ) +// UnpackInterfaces ensures AttesterInfo.Pubkey Any values have their cached +// concrete value populated after genesis JSON unmarshaling. Without this, the +// cosmos-sdk codec leaves the Any unresolved and GetPubKey() returns an error. +func (gs GenesisState) UnpackInterfaces(unpacker codectypes.AnyUnpacker) error { + for i := range gs.AttesterInfos { + if err := gs.AttesterInfos[i].UnpackInterfaces(unpacker); err != nil { + return fmt.Errorf("unpack attester %d: %w", i, err) + } + } + return nil +} + // DefaultGenesisState returns the default genesis state func DefaultGenesisState() *GenesisState { return &GenesisState{ diff --git a/modules/network/types/genesis.pb.go b/modules/network/types/genesis.pb.go index 539c86ad..020b5c99 100644 --- a/modules/network/types/genesis.pb.go +++ b/modules/network/types/genesis.pb.go @@ -31,6 +31,9 @@ type GenesisState struct { ValidatorIndices []ValidatorIndex `protobuf:"bytes,2,rep,name=validator_indices,json=validatorIndices,proto3" json:"validator_indices"` // attestation_bitmaps contains historical attestation data AttestationBitmaps []AttestationBitmap `protobuf:"bytes,3,rep,name=attestation_bitmaps,json=attestationBitmaps,proto3" json:"attestation_bitmaps"` + // attester_infos is the fixed attester set loaded at genesis. After chain + // start, the set is immutable (MsgJoin/MsgLeave are disabled). + AttesterInfos []AttesterInfo `protobuf:"bytes,4,rep,name=attester_infos,json=attesterInfos,proto3" json:"attester_infos"` } func (m *GenesisState) Reset() { *m = GenesisState{} } @@ -87,6 +90,13 @@ func (m *GenesisState) GetAttestationBitmaps() []AttestationBitmap { return nil } +func (m *GenesisState) GetAttesterInfos() []AttesterInfo { + if m != nil { + return m.AttesterInfos + } + return nil +} + func init() { proto.RegisterType((*GenesisState)(nil), "evabci.network.v1.GenesisState") } @@ -94,26 +104,28 @@ func init() { func init() { proto.RegisterFile("evabci/network/v1/genesis.proto", fileDescriptor_58e10cce12d8f51a) } var fileDescriptor_58e10cce12d8f51a = []byte{ - // 298 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0xd0, 0xbf, 0x4e, 0xeb, 0x30, - 0x14, 0xc7, 0xf1, 0xf8, 0xf6, 0xaa, 0x43, 0xca, 0x40, 0x03, 0x43, 0xa9, 0x84, 0x5b, 0x10, 0x43, - 0x17, 0x6c, 0x5a, 0x06, 0x66, 0xb2, 0x20, 0x98, 0x10, 0x20, 0x06, 0x18, 0x2a, 0x27, 0x39, 0x0a, - 0x56, 0x9b, 0x38, 0x8a, 0x4f, 0x0d, 0xbc, 0x05, 0x8f, 0xd5, 0xb1, 0x23, 0x13, 0x42, 0xc9, 0x5b, - 0x30, 0xa1, 0x3a, 0xe1, 0x8f, 0x68, 0xb7, 0x28, 0xe7, 0xab, 0x8f, 0xa5, 0x9f, 0xdb, 0x03, 0x23, - 0x82, 0x50, 0xf2, 0x14, 0xf0, 0x51, 0xe5, 0x13, 0x6e, 0x86, 0x3c, 0x86, 0x14, 0xb4, 0xd4, 0x2c, - 0xcb, 0x15, 0x2a, 0xaf, 0x5d, 0x05, 0xac, 0x0e, 0x98, 0x19, 0x76, 0xb7, 0x63, 0x15, 0x2b, 0x7b, - 0xe5, 0xcb, 0xaf, 0x2a, 0xec, 0xee, 0xae, 0x4a, 0xf8, 0x9c, 0x41, 0xed, 0xec, 0x7f, 0x10, 0x77, - 0xe3, 0xac, 0x92, 0xaf, 0x51, 0x20, 0x78, 0x27, 0x6e, 0x33, 0x13, 0xb9, 0x48, 0x74, 0x87, 0xf4, - 0xc9, 0xa0, 0x35, 0xda, 0x61, 0x2b, 0x2f, 0xb1, 0x4b, 0x1b, 0xf8, 0xff, 0xe7, 0x6f, 0x3d, 0xe7, - 0xaa, 0xce, 0xbd, 0x1b, 0xb7, 0x6d, 0xc4, 0x54, 0x46, 0x02, 0x55, 0x3e, 0x96, 0x69, 0x24, 0x43, - 0xd0, 0x9d, 0x7f, 0xfd, 0xc6, 0xa0, 0x35, 0xda, 0x5b, 0x63, 0xdc, 0x7e, 0xb5, 0xe7, 0x69, 0x04, - 0x4f, 0xb5, 0xb5, 0x69, 0x7e, 0xfd, 0x5d, 0x02, 0xde, 0xbd, 0xbb, 0x25, 0x10, 0x41, 0xa3, 0x40, - 0xa9, 0xd2, 0x71, 0x20, 0x31, 0x11, 0x99, 0xee, 0x34, 0xac, 0x7b, 0xb0, 0xc6, 0x3d, 0xfd, 0xa9, - 0x7d, 0x1b, 0xd7, 0xb4, 0x27, 0xfe, 0x1e, 0xb4, 0x7f, 0x31, 0x2f, 0x28, 0x59, 0x14, 0x94, 0xbc, - 0x17, 0x94, 0xbc, 0x94, 0xd4, 0x59, 0x94, 0xd4, 0x79, 0x2d, 0xa9, 0x73, 0x77, 0x14, 0x4b, 0x7c, - 0x98, 0x05, 0x2c, 0x54, 0x09, 0x07, 0xa3, 0x51, 0x84, 0x13, 0x0e, 0xe6, 0xd0, 0x2e, 0x99, 0xa8, - 0x68, 0x36, 0x05, 0xfd, 0xbd, 0xa8, 0x9d, 0x33, 0x68, 0xda, 0x3d, 0x8f, 0x3f, 0x03, 0x00, 0x00, - 0xff, 0xff, 0x66, 0xac, 0x9a, 0x06, 0xba, 0x01, 0x00, 0x00, + // 335 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x74, 0x91, 0xb1, 0x4f, 0xfa, 0x40, + 0x14, 0xc7, 0x5b, 0x20, 0x0c, 0xe5, 0xf7, 0x33, 0x52, 0x1d, 0x2a, 0x89, 0x05, 0x8d, 0x03, 0x8b, + 0xad, 0xe0, 0xe0, 0x2c, 0x8b, 0xc1, 0x38, 0x18, 0x35, 0x0e, 0x3a, 0x90, 0x6b, 0xfb, 0xa8, 0x17, + 0xe8, 0x5d, 0xd3, 0x7b, 0x9c, 0xfa, 0x5f, 0xf8, 0x67, 0x31, 0x32, 0x3a, 0x19, 0x03, 0xff, 0x88, + 0xe1, 0x7a, 0x28, 0xb1, 0xb8, 0x5d, 0xde, 0xf7, 0x93, 0xcf, 0xbd, 0xbc, 0xaf, 0xd5, 0x04, 0x49, + 0x82, 0x90, 0xfa, 0x0c, 0xf0, 0x99, 0x67, 0x23, 0x5f, 0x76, 0xfc, 0x18, 0x18, 0x08, 0x2a, 0xbc, + 0x34, 0xe3, 0xc8, 0xed, 0x7a, 0x0e, 0x78, 0x1a, 0xf0, 0x64, 0xa7, 0xb1, 0x1b, 0xf3, 0x98, 0xab, + 0xd4, 0x5f, 0xbe, 0x72, 0xb0, 0xb1, 0x5f, 0x34, 0xe1, 0x6b, 0x0a, 0xda, 0xd3, 0x68, 0x15, 0x63, + 0x82, 0x08, 0x02, 0x21, 0xcb, 0x89, 0xc3, 0x69, 0xc9, 0xfa, 0x77, 0x91, 0xff, 0x7d, 0x8b, 0x04, + 0xc1, 0x3e, 0xb3, 0xaa, 0x29, 0xc9, 0x48, 0x22, 0x1c, 0xb3, 0x65, 0xb6, 0x6b, 0xdd, 0x3d, 0xaf, + 0xb0, 0x8b, 0x77, 0xad, 0x80, 0x5e, 0x65, 0xfa, 0xd1, 0x34, 0x6e, 0x34, 0x6e, 0xdf, 0x59, 0x75, + 0x49, 0xc6, 0x34, 0x22, 0xc8, 0xb3, 0x01, 0x65, 0x11, 0x0d, 0x41, 0x38, 0xa5, 0x56, 0xb9, 0x5d, + 0xeb, 0x1e, 0x6c, 0x70, 0xdc, 0xaf, 0xd8, 0x3e, 0x8b, 0xe0, 0x45, 0xbb, 0xb6, 0xe5, 0xda, 0x74, + 0x29, 0xb0, 0x1f, 0xad, 0x9d, 0x7c, 0x63, 0x82, 0x94, 0xb3, 0x41, 0x40, 0x31, 0x21, 0xa9, 0x70, + 0xca, 0xca, 0x7b, 0xb4, 0xc1, 0x7b, 0xfe, 0x43, 0xf7, 0x14, 0xac, 0xd5, 0x36, 0xf9, 0x1d, 0x08, + 0xfb, 0xca, 0xda, 0x5a, 0x9d, 0x63, 0x40, 0xd9, 0x90, 0x0b, 0xa7, 0xa2, 0xbc, 0xcd, 0x3f, 0xbd, + 0x90, 0xf5, 0xd9, 0x90, 0x6b, 0xe5, 0x7f, 0xb2, 0x36, 0x13, 0xbd, 0xcb, 0xe9, 0xdc, 0x35, 0x67, + 0x73, 0xd7, 0xfc, 0x9c, 0xbb, 0xe6, 0xdb, 0xc2, 0x35, 0x66, 0x0b, 0xd7, 0x78, 0x5f, 0xb8, 0xc6, + 0xc3, 0x49, 0x4c, 0xf1, 0x69, 0x12, 0x78, 0x21, 0x4f, 0x7c, 0x90, 0x02, 0x49, 0x38, 0xf2, 0x41, + 0x1e, 0xab, 0x6a, 0x12, 0x1e, 0x4d, 0xc6, 0x20, 0xbe, 0x2b, 0x52, 0xf5, 0x05, 0x55, 0xd5, 0xce, + 0xe9, 0x57, 0x00, 0x00, 0x00, 0xff, 0xff, 0x34, 0x07, 0x1b, 0x4a, 0x2a, 0x02, 0x00, 0x00, } func (m *GenesisState) Marshal() (dAtA []byte, err error) { @@ -136,6 +148,20 @@ func (m *GenesisState) MarshalToSizedBuffer(dAtA []byte) (int, error) { _ = i var l int _ = l + if len(m.AttesterInfos) > 0 { + for iNdEx := len(m.AttesterInfos) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.AttesterInfos[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintGenesis(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x22 + } + } if len(m.AttestationBitmaps) > 0 { for iNdEx := len(m.AttestationBitmaps) - 1; iNdEx >= 0; iNdEx-- { { @@ -208,6 +234,12 @@ func (m *GenesisState) Size() (n int) { n += 1 + l + sovGenesis(uint64(l)) } } + if len(m.AttesterInfos) > 0 { + for _, e := range m.AttesterInfos { + l = e.Size() + n += 1 + l + sovGenesis(uint64(l)) + } + } return n } @@ -347,6 +379,40 @@ func (m *GenesisState) Unmarshal(dAtA []byte) error { return err } iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field AttesterInfos", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowGenesis + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthGenesis + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthGenesis + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.AttesterInfos = append(m.AttesterInfos, AttesterInfo{}) + if err := m.AttesterInfos[len(m.AttesterInfos)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex default: iNdEx = preIndex skippy, err := skipGenesis(dAtA[iNdEx:]) diff --git a/modules/proto/evabci/network/v1/attester.proto b/modules/proto/evabci/network/v1/attester.proto index e54e3a5d..e7d4f9d7 100644 --- a/modules/proto/evabci/network/v1/attester.proto +++ b/modules/proto/evabci/network/v1/attester.proto @@ -21,4 +21,9 @@ message AttesterInfo { // joined_height is the height at which the attester joined int64 joined_height = 3; + + // consensus_address is the bech32 cosmosvalcons1... derived from pubkey. + // Redundant with pubkey but persisted so the keeper's collections key + // (consensus address) matches the stored struct. + string consensus_address = 4 [(cosmos_proto.scalar) = "cosmos.AddressString"]; } \ No newline at end of file diff --git a/modules/proto/evabci/network/v1/genesis.proto b/modules/proto/evabci/network/v1/genesis.proto index 473c514a..8098c25b 100644 --- a/modules/proto/evabci/network/v1/genesis.proto +++ b/modules/proto/evabci/network/v1/genesis.proto @@ -6,6 +6,7 @@ option go_package = "github.com/evstack/ev-abci/modules/network/types"; import "gogoproto/gogo.proto"; import "evabci/network/v1/types.proto"; +import "evabci/network/v1/attester.proto"; // GenesisState defines the network module's genesis state. message GenesisState { @@ -17,4 +18,8 @@ message GenesisState { // attestation_bitmaps contains historical attestation data repeated AttestationBitmap attestation_bitmaps = 3 [(gogoproto.nullable) = false]; + + // attester_infos is the fixed attester set loaded at genesis. After chain + // start, the set is immutable (MsgJoin/MsgLeave are disabled). + repeated AttesterInfo attester_infos = 4 [(gogoproto.nullable) = false]; } From 64064acd3e508163f20e63118e6ccf2d2e8e2f85 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Tue, 28 Apr 2026 17:03:24 +0200 Subject: [PATCH 2/9] refactor(network): keep attester set fixed after genesis --- modules/network/keeper/abci.go | 9 +++-- modules/network/keeper/keeper.go | 40 ----------------------- modules/network/keeper/msg_server_test.go | 36 +++++++------------- 3 files changed, 15 insertions(+), 70 deletions(-) diff --git a/modules/network/keeper/abci.go b/modules/network/keeper/abci.go index f212f5b7..81dcc108 100644 --- a/modules/network/keeper/abci.go +++ b/modules/network/keeper/abci.go @@ -112,14 +112,13 @@ func (k Keeper) processEpochEnd(ctx sdk.Context, epoch uint64) error { } } - // todo (Alex): find a way to prune only bitmaps that are not used anymore + // Validator indices are established at genesis and never mutate at runtime + // (MsgJoin/MsgLeave are disabled). Nothing to rebuild here. + + // todo: find a way to prune only bitmaps that are not used anymore // if err := k.PruneOldBitmaps(ctx, epoch); err != nil { // return fmt.Errorf("pruning old data at epoch %d: %w", epoch, err) // } - - if err := k.BuildValidatorIndexMap(ctx); err != nil { - return fmt.Errorf("rebuilding validator index map at epoch %d: %w", epoch, err) - } return nil } diff --git a/modules/network/keeper/keeper.go b/modules/network/keeper/keeper.go index df3d8c50..206d1163 100644 --- a/modules/network/keeper/keeper.go +++ b/modules/network/keeper/keeper.go @@ -185,46 +185,6 @@ func (k Keeper) GetAllAttesters(ctx sdk.Context) ([]string, error) { return attesters, nil } -// BuildValidatorIndexMap rebuilds the validator index mapping -func (k Keeper) BuildValidatorIndexMap(ctx sdk.Context) error { - // Get all attesters instead of bonded validators - attesters, err := k.GetAllAttesters(ctx) - if err != nil { - return err - } - - // Guard against uint16 overflow (defense-in-depth). - if len(attesters) > int(^uint16(0)) { - return fmt.Errorf("attester count %d exceeds uint16 max %d", len(attesters), ^uint16(0)) - } - - // Clear existing indices and powers - // The `nil` range clears all entries in the collection. - if err := k.ValidatorIndex.Clear(ctx, nil); err != nil { - k.Logger(ctx).Error("failed to clear validator index", "error", err) - return err - } - if err := k.ValidatorPower.Clear(ctx, nil); err != nil { - k.Logger(ctx).Error("failed to clear validator power", "error", err) - return err - } - - // Build new indices for all attesters with voting power of 1 - index := uint16(0) - for _, attesterAddr := range attesters { - power := uint64(1) // Assign voting power of 1 to all attesters - if err := k.SetValidatorIndex(ctx, attesterAddr, index, power); err != nil { - // Consider how to handle partial failures; potentially log and continue or return error. - k.Logger(ctx).Error("failed to set validator index during build", "attester", attesterAddr, "error", err) - return err - } - k.Logger(ctx).Debug("assigned index to attester", "attester", attesterAddr, "index", index, "power", power) - index++ - } - k.Logger(ctx).Info("rebuilt validator index map for attesters", "count", len(attesters)) - return nil -} - // GetCurrentEpoch returns the current epoch number func (k Keeper) GetCurrentEpoch(ctx sdk.Context) uint64 { params := k.GetParams(ctx) diff --git a/modules/network/keeper/msg_server_test.go b/modules/network/keeper/msg_server_test.go index 02c648ba..9b3f99a9 100644 --- a/modules/network/keeper/msg_server_test.go +++ b/modules/network/keeper/msg_server_test.go @@ -20,7 +20,6 @@ import ( moduletestutil "github.com/cosmos/cosmos-sdk/types/module/testutil" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/evstack/ev-abci/modules/network/types" @@ -85,9 +84,9 @@ func TestAttestVotePayloadValidation(t *testing.T) { sk := NewMockStakingKeeper() server, keeper, ctx := newTestServer(t, &sk) require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) - require.NoError(t, keeper.SetAttesterSetMember(ctx, myValAddr.String())) require.NoError(t, keeper.SetAttesterInfo(ctx, myValAddr.String(), &types.AttesterInfo{Authority: myValAddr.String()})) - require.NoError(t, keeper.BuildValidatorIndexMap(ctx)) + require.NoError(t, keeper.SetAttesterSetMember(ctx, myValAddr.String())) + require.NoError(t, keeper.SetValidatorIndex(ctx, myValAddr.String(), 0, 1)) msg := &types.MsgAttest{ Authority: myValAddr.String(), @@ -108,7 +107,6 @@ func TestAttestVotePayloadValidation(t *testing.T) { } } - func TestAttest(t *testing.T) { ownerAddr := sdk.ValAddress("attester_owner") otherAddr := sdk.ValAddress("other_sender") @@ -124,13 +122,9 @@ func TestAttest(t *testing.T) { setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) { t.Helper() require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) - joinMsg := &types.MsgJoinAttesterSet{ - Authority: ownerAddr.String(), - ConsensusAddress: ownerAddr.String(), - } - _, err := server.JoinAttesterSet(ctx, joinMsg) - require.NoError(t, err) - require.NoError(t, keeper.BuildValidatorIndexMap(ctx)) + require.NoError(t, keeper.SetAttesterInfo(ctx, ownerAddr.String(), &types.AttesterInfo{Authority: ownerAddr.String()})) + require.NoError(t, keeper.SetAttesterSetMember(ctx, ownerAddr.String())) + require.NoError(t, keeper.SetValidatorIndex(ctx, ownerAddr.String(), 0, 1)) }, msg: &types.MsgAttest{ Authority: ownerAddr.String(), @@ -154,13 +148,9 @@ func TestAttest(t *testing.T) { "wrong_authority": { setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) { t.Helper() - joinMsg := &types.MsgJoinAttesterSet{ - Authority: ownerAddr.String(), - ConsensusAddress: ownerAddr.String(), - } - _, err := server.JoinAttesterSet(ctx, joinMsg) - require.NoError(t, err) - require.NoError(t, keeper.BuildValidatorIndexMap(ctx)) + require.NoError(t, keeper.SetAttesterInfo(ctx, ownerAddr.String(), &types.AttesterInfo{Authority: ownerAddr.String()})) + require.NoError(t, keeper.SetAttesterSetMember(ctx, ownerAddr.String())) + require.NoError(t, keeper.SetValidatorIndex(ctx, ownerAddr.String(), 0, 1)) }, msg: &types.MsgAttest{ Authority: otherAddr.String(), @@ -269,13 +259,9 @@ func TestAttestHeightBounds(t *testing.T) { require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) - joinMsg := &types.MsgJoinAttesterSet{ - Authority: ownerAddr.String(), - ConsensusAddress: myValAddr.String(), - } - _, err := server.JoinAttesterSet(ctx, joinMsg) - require.NoError(t, err) - require.NoError(t, keeper.BuildValidatorIndexMap(ctx)) + require.NoError(t, keeper.SetAttesterInfo(ctx, myValAddr.String(), &types.AttesterInfo{Authority: ownerAddr.String()})) + require.NoError(t, keeper.SetAttesterSetMember(ctx, myValAddr.String())) + require.NoError(t, keeper.SetValidatorIndex(ctx, myValAddr.String(), 0, 1)) // when msg := &types.MsgAttest{ From dd9e721d8cd5df0aa315a839bafef784c19b8b98 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 30 Apr 2026 14:30:30 +0200 Subject: [PATCH 3/9] refactor(network): remove inactive pruning code --- modules/network/keeper/abci.go | 5 ---- modules/network/keeper/keeper.go | 37 ---------------------------- modules/network/keeper/msg_server.go | 6 ++--- 3 files changed, 2 insertions(+), 46 deletions(-) diff --git a/modules/network/keeper/abci.go b/modules/network/keeper/abci.go index 81dcc108..80d59555 100644 --- a/modules/network/keeper/abci.go +++ b/modules/network/keeper/abci.go @@ -114,11 +114,6 @@ func (k Keeper) processEpochEnd(ctx sdk.Context, epoch uint64) error { // Validator indices are established at genesis and never mutate at runtime // (MsgJoin/MsgLeave are disabled). Nothing to rebuild here. - - // todo: find a way to prune only bitmaps that are not used anymore - // if err := k.PruneOldBitmaps(ctx, epoch); err != nil { - // return fmt.Errorf("pruning old data at epoch %d: %w", epoch, err) - // } return nil } diff --git a/modules/network/keeper/keeper.go b/modules/network/keeper/keeper.go index 206d1163..544d5d0f 100644 --- a/modules/network/keeper/keeper.go +++ b/modules/network/keeper/keeper.go @@ -262,43 +262,6 @@ func (k Keeper) IsSoftConfirmed(ctx sdk.Context, height int64) (bool, error) { return k.CheckQuorum(ctx, votedPower, totalPower) } -// PruneOldBitmaps removes bitmaps older than PruneAfter epochs -func (k Keeper) PruneOldBitmaps(ctx sdk.Context, currentEpoch uint64) error { - params := k.GetParams(ctx) - if params.PruneAfter == 0 { // Avoid pruning if PruneAfter is zero or not set - return nil - } - if currentEpoch <= params.PruneAfter { - return nil - } - - pruneBeforeEpoch := currentEpoch - params.PruneAfter - pruneHeight := int64(pruneBeforeEpoch * params.EpochLength) // Assuming EpochLength defines blocks per epoch - - // Prune attestation bitmaps (raw bitmaps) - attestationRange := new(collections.Range[int64]).StartInclusive(0).EndExclusive(pruneHeight) - if err := k.AttestationBitmap.Clear(ctx, attestationRange); err != nil { - return fmt.Errorf("clearing attestation bitmaps before height %d: %w", pruneHeight, err) - } - // Prune stored attestation info (full AttestationBitmap objects) - storedAttestationInfoRange := new(collections.Range[int64]).StartInclusive(0).EndExclusive(pruneHeight) - if err := k.StoredAttestationInfo.Clear(ctx, storedAttestationInfoRange); err != nil { - return fmt.Errorf("clearing stored attestation info before height %d: %w", pruneHeight, err) - } - - // Prune epoch bitmaps - epochRange := new(collections.Range[uint64]).StartInclusive(0).EndExclusive(pruneBeforeEpoch) - if err := k.EpochBitmap.Clear(ctx, epochRange); err != nil { - return fmt.Errorf("clearing epoch bitmaps before epoch %d: %w", pruneBeforeEpoch, err) - } - - // TODO: Consider pruning signatures associated with pruned heights. - // This would involve iterating k.Signatures and removing entries where height < pruneHeight. - - k.Logger(ctx).Info("Pruned old bitmaps and attestation info", "prunedBeforeEpoch", pruneBeforeEpoch, "prunedBeforeHeight", pruneHeight) - return nil -} - // SetSignature stores the vote signature for a given height and validator func (k Keeper) SetSignature(ctx sdk.Context, height int64, validatorAddr string, signature []byte) error { return k.Signatures.Set(ctx, collections.Join(height, validatorAddr), signature) diff --git a/modules/network/keeper/msg_server.go b/modules/network/keeper/msg_server.go index 17143579..a5b20ca3 100644 --- a/modules/network/keeper/msg_server.go +++ b/modules/network/keeper/msg_server.go @@ -60,10 +60,8 @@ func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.M return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "attestation height %d exceeds max allowed height %d", msg.Height, maxFutureHeight) } - // Enforce attestation height lower bound: reject heights that fall below - // the PruneAfter retention window. Attesting pruned/about-to-be-pruned - // heights wastes storage and serves no purpose. This uses the same epoch - // calculation as PruneOldBitmaps so the two stay aligned. + // Enforce attestation height lower bound so validators cannot submit + // attestations for heights outside the configured attestation window. params := k.GetParams(ctx) minHeight := int64(1) if params.PruneAfter > 0 && params.EpochLength > 0 { From 031b4109209615e4b5951c67d026161d52ff7d84 Mon Sep 17 00:00:00 2001 From: Randy Grok <98407738+randygrok@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:42:32 +0200 Subject: [PATCH 4/9] feat(network): verify attester votes (#395) --- modules/network/keeper/attester_ibc_test.go | 309 +++++++++ modules/network/keeper/keeper.go | 65 +- modules/network/keeper/msg_server.go | 123 +++- modules/network/keeper/msg_server_test.go | 666 ++++++++++++++++++-- modules/network/keeper/testhelpers_test.go | 67 ++ modules/network/types/expected_keepers.go | 7 + server/start.go | 17 + 7 files changed, 1150 insertions(+), 104 deletions(-) create mode 100644 modules/network/keeper/attester_ibc_test.go create mode 100644 modules/network/keeper/testhelpers_test.go diff --git a/modules/network/keeper/attester_ibc_test.go b/modules/network/keeper/attester_ibc_test.go new file mode 100644 index 00000000..5d12267d --- /dev/null +++ b/modules/network/keeper/attester_ibc_test.go @@ -0,0 +1,309 @@ +package keeper_test + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "sort" + "testing" + "time" + + cmted25519 "github.com/cometbft/cometbft/crypto/ed25519" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cmttypes "github.com/cometbft/cometbft/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/gogoproto/proto" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-abci/modules/network" + "github.com/evstack/ev-abci/modules/network/keeper" + "github.com/evstack/ev-abci/modules/network/types" +) + +// staticBlockIDProvider returns the same BlockID hash for every height — +// mirrors a fixed sequencer view inside unit tests. +type staticBlockIDProvider struct{ hash []byte } + +func (s staticBlockIDProvider) GetBlockID(_ context.Context, _ uint64) (*cmttypes.BlockID, error) { + return &cmttypes.BlockID{Hash: s.hash}, nil +} + +func TestAttesterCommitVerifiesAsIBCLightClient(t *testing.T) { + chainID := "ibc-test-chain" + const height int64 = 100 + + // 1. Three attesters with fresh keys. + privs := []cmted25519.PrivKey{ + cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), + } + attesters := make([]types.AttesterInfo, 0, len(privs)) + for _, p := range privs { + pub := p.PubKey().(cmted25519.PubKey) + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + ai, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + attesters = append(attesters, *ai) + } + + // 2. Set up keeper, init genesis with the 3 attesters, advance ctx to height. + k, ctx, _ := newKeeperForGenesis(t) + gs := types.GenesisState{ + Params: types.DefaultParams(), + AttesterInfos: attesters, + } + require.NoError(t, network.InitGenesis(ctx, k, gs)) + ctx = ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID, Height: height}). + WithChainID(chainID) + + // 3. Deterministic BlockID hash (what all attesters sign). + blockIDHash := ibcMakeBlockHash(fmt.Sprintf("height-%d", height)) + k.SetBlockIDProvider(staticBlockIDProvider{hash: blockIDHash}) + + // 4. Each attester signs and submits a real MsgAttest (signature-verified path). + msgServer := keeper.NewMsgServerImpl(k) + for _, p := range privs { + pub := p.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + authority := sdk.AccAddress(pub.Address()).String() + voteBytes := ibcSignVote(t, chainID, height, p, blockIDHash) + _, err := msgServer.Attest(ctx, &types.MsgAttest{ + Authority: authority, + ConsensusAddress: consAddr, + Height: height, + Vote: voteBytes, + }) + require.NoError(t, err, "MsgAttest rejected for consAddr=%s", consAddr) + } + + // 5. Read state and assemble a cmttypes.Commit in ValidatorIndex order. + commit := ibcAssembleCommit(t, k, ctx, height, blockIDHash) + + // 6. Canonical ValidatorSet (NewValidatorSet sorts by address asc, matching genesis). + valSet := ibcBuildValidatorSet(attesters) + blockID := cmttypes.BlockID{Hash: blockIDHash, PartSetHeader: cmttypes.PartSetHeader{}} + + // 7. 07-tendermint verification — the decisive assertion. + require.NoError(t, valSet.VerifyCommitLight(chainID, blockID, height, commit), + "reconstructed commit must pass 07-tendermint light-client verification") + require.Len(t, commit.Signatures, 3, "every set member must appear in commit") + for _, cs := range commit.Signatures { + require.Equal(t, cmttypes.BlockIDFlagCommit, cs.BlockIDFlag) + } +} + +func TestAttesterCommit_BelowQuorum(t *testing.T) { + chainID := "ibc-test-chain" + const height int64 = 200 + + privs := []cmted25519.PrivKey{ + cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), + } + attesters := make([]types.AttesterInfo, 0, len(privs)) + for _, p := range privs { + pub := p.PubKey().(cmted25519.PubKey) + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + ai, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + attesters = append(attesters, *ai) + } + + k, ctx, _ := newKeeperForGenesis(t) + gs := types.GenesisState{ + Params: types.DefaultParams(), + AttesterInfos: attesters, + } + require.NoError(t, network.InitGenesis(ctx, k, gs)) + ctx = ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID, Height: height}). + WithChainID(chainID) + + blockIDHash := ibcMakeBlockHash(fmt.Sprintf("height-%d", height)) + k.SetBlockIDProvider(staticBlockIDProvider{hash: blockIDHash}) + + // Only 1 of 3 signs — below 2/3 quorum; LastAttestedHeight must not advance. + msgServer := keeper.NewMsgServerImpl(k) + p := privs[0] + pub := p.PubKey().(cmted25519.PubKey) + _, err := msgServer.Attest(ctx, &types.MsgAttest{ + Authority: sdk.AccAddress(pub.Address()).String(), + ConsensusAddress: sdk.ConsAddress(pub.Address()).String(), + Height: height, + Vote: ibcSignVote(t, chainID, height, p, blockIDHash), + }) + require.NoError(t, err) + + lastAttested, err := k.GetLastAttestedHeight(ctx) + require.NoError(t, err) + require.Less(t, lastAttested, height, "LastAttestedHeight should not advance below quorum") +} + +func TestAttesterCommit_ExactTwoThirdsDoesNotAdvanceLastAttestedHeight(t *testing.T) { + chainID := "ibc-test-chain" + const height int64 = 201 + + privs := []cmted25519.PrivKey{ + cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), + } + attesters := make([]types.AttesterInfo, 0, len(privs)) + for _, p := range privs { + pub := p.PubKey().(cmted25519.PubKey) + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + ai, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + attesters = append(attesters, *ai) + } + + k, ctx, _ := newKeeperForGenesis(t) + gs := types.GenesisState{ + Params: types.DefaultParams(), + AttesterInfos: attesters, + } + require.NoError(t, network.InitGenesis(ctx, k, gs)) + ctx = ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID, Height: height}). + WithChainID(chainID) + + blockIDHash := ibcMakeBlockHash(fmt.Sprintf("height-%d", height)) + k.SetBlockIDProvider(staticBlockIDProvider{hash: blockIDHash}) + + msgServer := keeper.NewMsgServerImpl(k) + for _, p := range privs[:2] { + pub := p.PubKey().(cmted25519.PubKey) + _, err := msgServer.Attest(ctx, &types.MsgAttest{ + Authority: sdk.AccAddress(pub.Address()).String(), + ConsensusAddress: sdk.ConsAddress(pub.Address()).String(), + Height: height, + Vote: ibcSignVote(t, chainID, height, p, blockIDHash), + }) + require.NoError(t, err) + } + + lastAttested, err := k.GetLastAttestedHeight(ctx) + require.NoError(t, err) + require.Less(t, lastAttested, height, "exactly 2/3 voting power must not advance LastAttestedHeight") +} + +// -- helpers -- + +func ibcMakeBlockHash(seed string) []byte { + h := sha256.Sum256([]byte(seed)) + return h[:] +} + +// ibcSignVote builds and signs a precommit vote, returning the marshaled proto bytes. +func ibcSignVote(t *testing.T, chainID string, height int64, priv cmted25519.PrivKey, blockIDHash []byte) []byte { + t.Helper() + pub := priv.PubKey().(cmted25519.PubKey) + v := cmtproto.Vote{ + Type: cmtproto.PrecommitType, + Height: height, + Round: 0, + BlockID: cmtproto.BlockID{Hash: blockIDHash, PartSetHeader: cmtproto.PartSetHeader{}}, + Timestamp: time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC), + ValidatorAddress: pub.Address(), + ValidatorIndex: 0, + } + sb := cmttypes.VoteSignBytes(chainID, &v) + sig, err := priv.Sign(sb) + require.NoError(t, err) + v.Signature = sig + out, err := proto.Marshal(&v) + require.NoError(t, err) + return out +} + +func ibcBuildValidatorSet(attesters []types.AttesterInfo) *cmttypes.ValidatorSet { + vals := make([]*cmttypes.Validator, 0, len(attesters)) + for _, a := range attesters { + pk, err := a.GetPubKey() + if err != nil { + panic(err) + } + cmtPk, err := cryptocodec.ToCmtPubKeyInterface(pk) + if err != nil { + panic(err) + } + vals = append(vals, cmttypes.NewValidator(cmtPk, 1)) + } + // NewValidatorSet sorts internally by address ascending. + return cmttypes.NewValidatorSet(vals) +} + +// ibcAssembleCommit reads ValidatorIndex + Signatures from keeper state and +// assembles a Commit ordered by ValidatorIndex (mirrors the /commit RPC path). +func ibcAssembleCommit(t *testing.T, k keeper.Keeper, ctx sdk.Context, height int64, blockIDHash []byte) *cmttypes.Commit { + t.Helper() + + type entry struct { + consAddr string + addr []byte + index uint16 + } + + var entries []entry + require.NoError(t, k.ValidatorIndex.Walk(ctx, nil, func(addr string, idx uint16) (bool, error) { + info, err := k.GetAttesterInfo(ctx, addr) + if err != nil { + return false, err + } + pk, err := info.GetPubKey() + if err != nil { + return false, err + } + entries = append(entries, entry{ + consAddr: addr, + addr: pk.Address(), + index: idx, + }) + return false, nil + })) + sort.Slice(entries, func(i, j int) bool { return entries[i].index < entries[j].index }) + + sigs := make([]cmttypes.CommitSig, 0, len(entries)) + for _, e := range entries { + has, err := k.HasSignature(ctx, height, e.consAddr) + require.NoError(t, err) + if !has { + sigs = append(sigs, cmttypes.CommitSig{BlockIDFlag: cmttypes.BlockIDFlagAbsent}) + continue + } + voteBytes, err := k.GetSignature(ctx, height, e.consAddr) + require.NoError(t, err) + var vote cmtproto.Vote + require.NoError(t, proto.Unmarshal(voteBytes, &vote)) + sigs = append(sigs, cmttypes.CommitSig{ + BlockIDFlag: cmttypes.BlockIDFlagCommit, + ValidatorAddress: e.addr, + Timestamp: vote.Timestamp, + Signature: vote.Signature, + }) + } + + // Sanity: validator addresses in commit order must be ascending. + prev := []byte(nil) + for _, s := range sigs { + if s.BlockIDFlag == cmttypes.BlockIDFlagCommit && prev != nil { + require.True(t, bytes.Compare(prev, s.ValidatorAddress) < 0, + "validator addresses not ascending in commit") + } + if s.BlockIDFlag == cmttypes.BlockIDFlagCommit { + prev = s.ValidatorAddress + } + } + + return &cmttypes.Commit{ + Height: height, + Round: 0, + BlockID: cmttypes.BlockID{Hash: blockIDHash, PartSetHeader: cmttypes.PartSetHeader{}}, + Signatures: sigs, + } +} diff --git a/modules/network/keeper/keeper.go b/modules/network/keeper/keeper.go index 544d5d0f..64823ba2 100644 --- a/modules/network/keeper/keeper.go +++ b/modules/network/keeper/keeper.go @@ -3,6 +3,7 @@ package keeper import ( "errors" "fmt" + "sort" "cosmossdk.io/collections" "cosmossdk.io/core/store" @@ -14,6 +15,12 @@ import ( "github.com/evstack/ev-abci/modules/network/types" ) +// mutableState holds keeper fields that must remain observable across value +// copies of Keeper (e.g. the copy captured by msgServer at wiring time). +type mutableState struct { + blockIDProvider types.BlockIDProvider +} + // Keeper of the network store type Keeper struct { cdc codec.BinaryCodec @@ -22,6 +29,7 @@ type Keeper struct { bankKeeper types.BankKeeper authority string bitmapHelper *BitmapHelper + mut *mutableState // Collections for state management ValidatorIndex collections.Map[string, uint16] @@ -55,6 +63,7 @@ func NewKeeper( bankKeeper: bk, authority: authority, bitmapHelper: NewBitmapHelper(), + mut: &mutableState{}, ValidatorIndex: collections.NewMap(sb, types.ValidatorIndexPrefix, "validator_index", collections.StringKey, collections.Uint16Value), ValidatorPower: collections.NewMap(sb, types.ValidatorPowerPrefix, "validator_power", collections.Uint16Key, collections.Uint64Value), @@ -81,6 +90,22 @@ func (k Keeper) GetAuthority() string { return k.authority } +// SetBlockIDProvider wires the source of canonical BlockID hashes used to pin +// attester votes. Must be called once at app-wiring time (post-depinject). +// The provider is stored on a shared mutableState so value-copies of Keeper +// (notably the one captured by msgServer) observe the update. +func (k Keeper) SetBlockIDProvider(p types.BlockIDProvider) { + k.mut.blockIDProvider = p +} + +// blockIDProvider returns the wired provider, or nil if none has been set. +func (k Keeper) blockIDProvider() types.BlockIDProvider { + if k.mut == nil { + return nil + } + return k.mut.blockIDProvider +} + // Logger returns a module-specific logger func (k Keeper) Logger(ctx sdk.Context) log.Logger { return ctx.Logger().With("module", "network") @@ -231,13 +256,11 @@ func (k Keeper) GetTotalPower(ctx sdk.Context) (uint64, error) { // CheckQuorum checks if the voted power meets quorum func (k Keeper) CheckQuorum(ctx sdk.Context, votedPower, totalPower uint64) (bool, error) { params := k.GetParams(ctx) - quorumFrac, err := math.LegacyNewDecFromStr(params.QuorumFraction) - if err != nil { + if _, err := math.LegacyNewDecFromStr(params.QuorumFraction); err != nil { return false, fmt.Errorf("invalid quorum fraction: %w", err) } - requiredPower := math.LegacyNewDec(int64(totalPower)).Mul(quorumFrac).TruncateInt().Uint64() - return votedPower >= requiredPower, nil + return votedPower*3 > totalPower*2, nil } // IsSoftConfirmed checks if a block at a given height is soft-confirmed @@ -290,28 +313,36 @@ func (k Keeper) GetAllSignaturesForHeight(ctx sdk.Context, height int64) (map[st return signatures, nil // No attestations for this height } - // Get all attesters to map indices to addresses - attesters, err := k.GetAllAttesters(ctx) - if err != nil { - return nil, fmt.Errorf("get all attesters: %w", err) + type indexedAttester struct { + addr string + index uint16 } - // Check each attester to see if they signed - for i, attesterAddr := range attesters { - if i >= len(bitmap)*8 { - break // Don't go beyond bitmap size + var attesters []indexedAttester + if err := k.ValidatorIndex.Walk(ctx, nil, func(addr string, index uint16) (bool, error) { + attesters = append(attesters, indexedAttester{addr: addr, index: index}) + return false, nil + }); err != nil { + return nil, fmt.Errorf("walk validator index: %w", err) + } + sort.Slice(attesters, func(i, j int) bool { + return attesters[i].index < attesters[j].index + }) + + for _, attester := range attesters { + if int(attester.index) >= len(bitmap)*8 { + continue } - // Check if this attester signed (bit is set in bitmap) - if k.bitmapHelper.IsSet(bitmap, i) { - signature, err := k.GetSignature(ctx, height, attesterAddr) + if k.bitmapHelper.IsSet(bitmap, int(attester.index)) { + signature, err := k.GetSignature(ctx, height, attester.addr) if err != nil && !errors.Is(err, collections.ErrNotFound) { k.Logger(ctx).Error("failed to get signature for attester", - "height", height, "attester", attesterAddr, "error", err) + "height", height, "attester", attester.addr, "error", err) continue } if signature != nil { - signatures[attesterAddr] = signature + signatures[attester.addr] = signature } } } diff --git a/modules/network/keeper/msg_server.go b/modules/network/keeper/msg_server.go index a5b20ca3..dd66d985 100644 --- a/modules/network/keeper/msg_server.go +++ b/modules/network/keeper/msg_server.go @@ -1,6 +1,7 @@ package keeper import ( + "bytes" "context" "errors" "fmt" @@ -8,17 +9,16 @@ import ( "cosmossdk.io/collections" sdkerr "cosmossdk.io/errors" "cosmossdk.io/math" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cmttypes "github.com/cometbft/cometbft/types" sdk "github.com/cosmos/cosmos-sdk/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" + "github.com/cosmos/gogoproto/proto" "github.com/evstack/ev-abci/modules/network/types" ) -// MinVoteLen is the minimum vote payload length in bytes. -// 64 is the size of a Ed25519 signature -const MinVoteLen = 64 - type msgServer struct { Keeper } @@ -34,16 +34,12 @@ var _ types.MsgServer = msgServer{} func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.MsgAttestResponse, error) { ctx := sdk.UnwrapSDKContext(goCtx) - if k.GetParams(ctx).SignMode == types.SignMode_SIGN_MODE_CHECKPOINT && - !k.IsCheckpointHeight(ctx, msg.Height) { - return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "height %d is not a checkpoint", msg.Height) - } - - if len(msg.Vote) < MinVoteLen { - return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "vote payload too short: got %d bytes, minimum %d", len(msg.Vote), MinVoteLen) + if err := k.assertValidValidatorAuthority(ctx, msg.ConsensusAddress, msg.Authority); err != nil { + return nil, err } - if err := k.assertValidValidatorAuthority(ctx, msg.ConsensusAddress, msg.Authority); err != nil { + // Verify the vote: decode, internal checks, signature check. + if _, err := k.verifyVote(ctx, msg.ConsensusAddress, msg.Vote, msg.Height); err != nil { return nil, err } @@ -52,8 +48,7 @@ func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.M return nil, sdkerr.Wrapf(sdkerrors.ErrNotFound, "validator index not found for %s", msg.ConsensusAddress) } - // Enforce attestation height upper bound to prevent storage exhaustion - // from future-height spam. + // Height bounds currentHeight := ctx.BlockHeight() maxFutureHeight := currentHeight + 1 if msg.Height > maxFutureHeight { @@ -73,6 +68,7 @@ func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.M if msg.Height < minHeight { return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "attestation height %d is below retention window (min %d)", msg.Height, minHeight) } + bitmap, err := k.GetAttestationBitmap(ctx, msg.Height) if err != nil && !errors.Is(err, collections.ErrNotFound) { return nil, sdkerr.Wrap(err, "get attestation bitmap") @@ -82,51 +78,39 @@ func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.M if err != nil { return nil, err } - numAttesters := len(attesters) - bitmap = k.bitmapHelper.NewBitmap(numAttesters) + bitmap = k.bitmapHelper.NewBitmap(len(attesters)) } if k.bitmapHelper.IsSet(bitmap, int(index)) { return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "consensus address %s already attested for height %d", msg.ConsensusAddress, msg.Height) } - // Set the bit k.bitmapHelper.SetBit(bitmap, int(index)) if err := k.SetAttestationBitmap(ctx, msg.Height, bitmap); err != nil { return nil, sdkerr.Wrap(err, "set attestation bitmap") } - - // Store signature using the consensus address (this is the key fix for IBC) if err := k.SetSignature(ctx, msg.Height, msg.ConsensusAddress, msg.Vote); err != nil { return nil, sdkerr.Wrap(err, "store signature") } - // Check if quorum is reached after this attestation votedPower, err := k.CalculateVotedPower(ctx, bitmap) if err != nil { return nil, sdkerr.Wrap(err, "calculate voted power") } - totalPower, err := k.GetTotalPower(ctx) if err != nil { return nil, sdkerr.Wrap(err, "get total power") } - quorumReached, err := k.CheckQuorum(ctx, votedPower, totalPower) if err != nil { return nil, sdkerr.Wrap(err, "check quorum") } - - // If quorum is reached, update the last attested height if quorumReached { if err := k.UpdateLastAttestedHeight(ctx, msg.Height); err != nil { return nil, sdkerr.Wrap(err, "update last attested height") } - k.Logger(ctx).Info("block reached quorum and is now soft confirmed", - "height", msg.Height, - "voted_power", votedPower, - "total_power", totalPower) + "height", msg.Height, "voted_power", votedPower, "total_power", totalPower) } epoch := k.GetCurrentEpoch(ctx) @@ -136,15 +120,13 @@ func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.M if err != nil { return nil, err } - numAttesters := len(attesters) - epochBitmap = k.bitmapHelper.NewBitmap(numAttesters) + epochBitmap = k.bitmapHelper.NewBitmap(len(attesters)) } k.bitmapHelper.SetBit(epochBitmap, int(index)) if err := k.SetEpochBitmap(ctx, epoch, epochBitmap); err != nil { return nil, sdkerr.Wrap(err, "set epoch bitmap") } - // Emit event ctx.EventManager().EmitEvent( sdk.NewEvent( types.TypeMsgAttest, @@ -153,7 +135,6 @@ func (k msgServer) Attest(goCtx context.Context, msg *types.MsgAttest) (*types.M sdk.NewAttribute("height", math.NewInt(msg.Height).String()), ), ) - return &types.MsgAttestResponse{}, nil } @@ -183,6 +164,84 @@ func (k msgServer) assertValidValidatorAuthority(ctx sdk.Context, consensusAddre return nil } +// verifyVote decodes vote bytes, performs internal-consistency checks, and +// verifies the signature against the pubkey registered for consensusAddress. +// Returns the decoded vote on success. +func (k msgServer) verifyVote(ctx sdk.Context, consensusAddress string, voteBytes []byte, msgHeight int64) (*cmtproto.Vote, error) { + var v cmtproto.Vote + if err := proto.Unmarshal(voteBytes, &v); err != nil { + return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "unmarshal vote: %v", err) + } + if v.Type != cmtproto.PrecommitType { + return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "vote type must be Precommit, got %s", v.Type) + } + if v.Height != msgHeight { + return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "vote height %d != msg height %d", v.Height, msgHeight) + } + if v.Round != 0 { + return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, "vote round must be 0, got %d", v.Round) + } + + info, err := k.AttesterInfo.Get(ctx, consensusAddress) + if err != nil { + if errors.Is(err, collections.ErrNotFound) { + return nil, sdkerr.Wrapf(sdkerrors.ErrNotFound, "consensus address %s not registered", consensusAddress) + } + return nil, sdkerr.Wrap(err, "get attester info") + } + pk, err := info.GetPubKey() + if err != nil { + return nil, sdkerr.Wrap(err, "decode pubkey") + } + if !bytes.Equal(v.ValidatorAddress, pk.Address()) { + return nil, sdkerr.Wrapf(sdkerrors.ErrUnauthorized, + "vote validator address %X does not match registered pubkey address %X", + v.ValidatorAddress, pk.Address()) + } + voteBlockID, err := cmttypes.BlockIDFromProto(&v.BlockID) + if err != nil { + return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, + "invalid vote BlockID: %v", err) + } + + sig := v.Signature + v.Signature = nil + signBytes := cmttypes.VoteSignBytes(ctx.ChainID(), &v) + if !pk.VerifySignature(signBytes, sig) { + return nil, sdkerr.Wrapf(sdkerrors.ErrUnauthorized, "invalid vote signature") + } + v.Signature = sig + + // Pin the full signed BlockID to the sequencer's real BlockID for this + // height. CometBFT sign bytes include both Hash and PartSetHeader; accepting + // a vote over a different PartSetHeader would later fail VerifyCommitLight. + provider := k.blockIDProvider() + if provider == nil { + return nil, sdkerr.Wrap(sdkerrors.ErrLogic, + "block ID provider not wired; cannot verify vote BlockID") + } + storedID, err := provider.GetBlockID(ctx, uint64(msgHeight)) + if err != nil { + return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, + "get block ID for height %d: %v", msgHeight, err) + } + if storedID == nil { + return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, + "block ID for height %d not found", msgHeight) + } + if !storedID.Equals(*voteBlockID) { + return nil, sdkerr.Wrapf(sdkerrors.ErrInvalidRequest, + "vote BlockID %v does not match sequencer BlockID %v at height %d", + voteBlockID, storedID, msgHeight) + } + return &v, nil +} + +// VerifyVoteForTest exposes verifyVote for unit testing. Not for production use. +func (k Keeper) VerifyVoteForTest(ctx sdk.Context, consensusAddress string, voteBytes []byte, msgHeight int64) (*cmtproto.Vote, error) { + return msgServer{Keeper: k}.verifyVote(ctx, consensusAddress, voteBytes, msgHeight) +} + // UpdateParams handles MsgUpdateParams func (k msgServer) UpdateParams(goCtx context.Context, msg *types.MsgUpdateParams) (*types.MsgUpdateParamsResponse, error) { ctx := sdk.UnwrapSDKContext(goCtx) diff --git a/modules/network/keeper/msg_server_test.go b/modules/network/keeper/msg_server_test.go index 9b3f99a9..74ae05d7 100644 --- a/modules/network/keeper/msg_server_test.go +++ b/modules/network/keeper/msg_server_test.go @@ -3,16 +3,22 @@ package keeper import ( "bytes" "context" + "errors" + "fmt" "maps" "slices" "strings" "testing" "time" + "cosmossdk.io/collections" "cosmossdk.io/log" "cosmossdk.io/math" storetypes "cosmossdk.io/store/types" + cmted25519 "github.com/cometbft/cometbft/crypto/ed25519" cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cmttypes "github.com/cometbft/cometbft/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" "github.com/cosmos/cosmos-sdk/runtime" "github.com/cosmos/cosmos-sdk/testutil/integration" sdk "github.com/cosmos/cosmos-sdk/types" @@ -20,11 +26,26 @@ import ( moduletestutil "github.com/cosmos/cosmos-sdk/types/module/testutil" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" + "github.com/cosmos/gogoproto/proto" "github.com/stretchr/testify/require" "github.com/evstack/ev-abci/modules/network/types" ) +type failingBlockIDProvider struct { + err error +} + +func (f failingBlockIDProvider) GetBlockID(_ context.Context, _ uint64) (*cmttypes.BlockID, error) { + return nil, f.err +} + +type nilBlockIDProvider struct{} + +func (nilBlockIDProvider) GetBlockID(_ context.Context, _ uint64) (*cmttypes.BlockID, error) { + return nil, nil +} + func TestJoinAttesterSetDisabled(t *testing.T) { sk := NewMockStakingKeeper() server, _, ctx := newTestServer(t, &sk) @@ -53,7 +74,13 @@ func TestLeaveAttesterSetDisabled(t *testing.T) { } func TestAttestVotePayloadValidation(t *testing.T) { - myValAddr := sdk.ValAddress("validator1") + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + blockHash := bytes.Repeat([]byte{0x01}, 32) + + consAddr := sdk.ConsAddress(pub.Address()).String() + authorityAddr := sdk.AccAddress(pub.Address()).String() specs := map[string]struct { vote []byte @@ -67,32 +94,52 @@ func TestAttestVotePayloadValidation(t *testing.T) { vote: nil, expErr: sdkerrors.ErrInvalidRequest, }, - "short vote rejected": { - vote: make([]byte, MinVoteLen-1), + "random bytes rejected": { + vote: bytes.Repeat([]byte{0x01}, 64), expErr: sdkerrors.ErrInvalidRequest, }, - "min-length vote accepted": { - vote: make([]byte, MinVoteLen), - }, - "valid-length vote accepted": { - vote: make([]byte, 96), + "valid signed vote accepted": { + vote: nil, // populated below per-subtest }, } for name, spec := range specs { t.Run(name, func(t *testing.T) { sk := NewMockStakingKeeper() - server, keeper, ctx := newTestServer(t, &sk) + cdc := moduletestutil.MakeTestEncodingConfig().Codec + keys := storetypes.NewKVStoreKeys(types.StoreKey) + logger := log.NewTestLogger(t) + cms := integration.CreateMultiStore(keys, logger) + authority := authtypes.NewModuleAddress("gov") + keeper := NewKeeper(cdc, runtime.NewKVStoreService(keys[types.StoreKey]), &sk, nil, nil, authority.String()) + keeper.SetBlockIDProvider(staticBlockIDProvider{hash: blockHash}) + server := msgServer{Keeper: keeper} + ctx := sdk.NewContext(cms, cmtproto.Header{ + ChainID: chainID, + Time: time.Now().UTC(), + Height: 10, + }, false, logger).WithContext(t.Context()) + require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) - require.NoError(t, keeper.SetAttesterInfo(ctx, myValAddr.String(), &types.AttesterInfo{Authority: myValAddr.String()})) - require.NoError(t, keeper.SetAttesterSetMember(ctx, myValAddr.String())) - require.NoError(t, keeper.SetValidatorIndex(ctx, myValAddr.String(), 0, 1)) + + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(authorityAddr, sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + require.NoError(t, keeper.SetAttesterSetMember(ctx, consAddr)) + require.NoError(t, keeper.SetValidatorIndex(ctx, consAddr, 0, 1)) + + vote := spec.vote + if name == "valid signed vote accepted" { + vote = signTestVote(t, chainID, 10, priv, blockHash) + } msg := &types.MsgAttest{ - Authority: myValAddr.String(), - ConsensusAddress: myValAddr.String(), + Authority: authorityAddr, + ConsensusAddress: consAddr, Height: 10, - Vote: spec.vote, + Vote: vote, } rsp, err := server.Attest(ctx, msg) @@ -108,55 +155,74 @@ func TestAttestVotePayloadValidation(t *testing.T) { } func TestAttest(t *testing.T) { - ownerAddr := sdk.ValAddress("attester_owner") + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + authorityAddr := sdk.AccAddress(pub.Address()).String() otherAddr := sdk.ValAddress("other_sender") + blockHash := bytes.Repeat([]byte{0x01}, 32) type testCase struct { - setup func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) - msg *types.MsgAttest + setup func(t *testing.T, ctx sdk.Context, keeper *Keeper) + msg func() *types.MsgAttest expErr error } tests := map[string]testCase{ "valid": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) { + setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper) { t.Helper() require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) - require.NoError(t, keeper.SetAttesterInfo(ctx, ownerAddr.String(), &types.AttesterInfo{Authority: ownerAddr.String()})) - require.NoError(t, keeper.SetAttesterSetMember(ctx, ownerAddr.String())) - require.NoError(t, keeper.SetValidatorIndex(ctx, ownerAddr.String(), 0, 1)) + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(authorityAddr, sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + require.NoError(t, keeper.SetAttesterSetMember(ctx, consAddr)) + require.NoError(t, keeper.SetValidatorIndex(ctx, consAddr, 0, 1)) }, - msg: &types.MsgAttest{ - Authority: ownerAddr.String(), - ConsensusAddress: ownerAddr.String(), - Height: 10, - Vote: bytes.Repeat([]byte{0x01}, 64), + msg: func() *types.MsgAttest { + return &types.MsgAttest{ + Authority: authorityAddr, + ConsensusAddress: consAddr, + Height: 10, + Vote: signTestVote(t, chainID, 10, priv, blockHash), + } }, }, "not_in_set": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) { + setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper) { t.Helper() }, - msg: &types.MsgAttest{ - Authority: ownerAddr.String(), - ConsensusAddress: ownerAddr.String(), - Height: 10, - Vote: bytes.Repeat([]byte{0x01}, 64), + msg: func() *types.MsgAttest { + return &types.MsgAttest{ + Authority: authorityAddr, + ConsensusAddress: consAddr, + Height: 10, + Vote: bytes.Repeat([]byte{0x01}, 64), + } }, expErr: sdkerrors.ErrUnauthorized, }, "wrong_authority": { - setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper, server msgServer) { + setup: func(t *testing.T, ctx sdk.Context, keeper *Keeper) { t.Helper() - require.NoError(t, keeper.SetAttesterInfo(ctx, ownerAddr.String(), &types.AttesterInfo{Authority: ownerAddr.String()})) - require.NoError(t, keeper.SetAttesterSetMember(ctx, ownerAddr.String())) - require.NoError(t, keeper.SetValidatorIndex(ctx, ownerAddr.String(), 0, 1)) + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(authorityAddr, sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + require.NoError(t, keeper.SetAttesterSetMember(ctx, consAddr)) + require.NoError(t, keeper.SetValidatorIndex(ctx, consAddr, 0, 1)) }, - msg: &types.MsgAttest{ - Authority: otherAddr.String(), - ConsensusAddress: ownerAddr.String(), - Height: 10, - Vote: bytes.Repeat([]byte{0x01}, 64), + msg: func() *types.MsgAttest { + return &types.MsgAttest{ + Authority: otherAddr.String(), + ConsensusAddress: consAddr, + Height: 10, + Vote: bytes.Repeat([]byte{0x01}, 64), + } }, expErr: sdkerrors.ErrUnauthorized, }, @@ -166,9 +232,9 @@ func TestAttest(t *testing.T) { sk := NewMockStakingKeeper() server, keeper, ctx := newTestServer(t, &sk) - spec.setup(t, ctx, &keeper, server) + spec.setup(t, ctx, &keeper) - rsp, err := server.Attest(ctx, spec.msg) + rsp, err := server.Attest(ctx, spec.msg()) if spec.expErr != nil { require.ErrorIs(t, err, spec.expErr) require.Nil(t, rsp) @@ -180,6 +246,80 @@ func TestAttest(t *testing.T) { } } +func TestAttestDuplicateDoesNotOverwriteState(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + authorityAddr := sdk.AccAddress(pub.Address()).String() + blockHash := bytes.Repeat([]byte{0x01}, 32) + + sk := NewMockStakingKeeper() + server, keeper, ctx := newTestServer(t, &sk) + keeper.SetBlockIDProvider(staticBlockIDProvider{hash: blockHash}) + require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) + require.NoError(t, registerTestAttester(ctx, &keeper, authorityAddr, consAddr, pub, 0)) + + firstVote := signTestVoteAt(t, chainID, 10, priv, blockHash, testTimeUTC()) + _, err := server.Attest(ctx, &types.MsgAttest{ + Authority: authorityAddr, + ConsensusAddress: consAddr, + Height: 10, + Vote: firstVote, + }) + require.NoError(t, err) + + secondVote := signTestVoteAt(t, chainID, 10, priv, blockHash, testTimeUTC().Add(time.Second)) + _, err = server.Attest(ctx, &types.MsgAttest{ + Authority: authorityAddr, + ConsensusAddress: consAddr, + Height: 10, + Vote: secondVote, + }) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + require.Contains(t, err.Error(), "already attested") + + storedVote, err := keeper.GetSignature(ctx, 10, consAddr) + require.NoError(t, err) + require.Equal(t, firstVote, storedVote) + + bitmap, err := keeper.GetAttestationBitmap(ctx, 10) + require.NoError(t, err) + require.Equal(t, 1, keeper.bitmapHelper.PopCount(bitmap)) + require.True(t, keeper.bitmapHelper.IsSet(bitmap, 0)) +} + +func TestAttestRejectedVoteDoesNotWriteBitmapOrSignature(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + authorityAddr := sdk.AccAddress(pub.Address()).String() + sequencerHash := bytes.Repeat([]byte{0xaa}, 32) + forgedHash := bytes.Repeat([]byte{0xff}, 32) + + sk := NewMockStakingKeeper() + server, keeper, ctx := newTestServer(t, &sk) + keeper.SetBlockIDProvider(staticBlockIDProvider{hash: sequencerHash}) + require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) + require.NoError(t, registerTestAttester(ctx, &keeper, authorityAddr, consAddr, pub, 0)) + + _, err := server.Attest(ctx, &types.MsgAttest{ + Authority: authorityAddr, + ConsensusAddress: consAddr, + Height: 10, + Vote: signTestVote(t, chainID, 10, priv, forgedHash), + }) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + + _, err = keeper.GetAttestationBitmap(ctx, 10) + require.ErrorIs(t, err, collections.ErrNotFound) + + hasSignature, err := keeper.HasSignature(ctx, 10, consAddr) + require.NoError(t, err) + require.False(t, hasSignature) +} + func newTestServer(t *testing.T, sk *MockStakingKeeper) (msgServer, Keeper, sdk.Context) { t.Helper() cdc := moduletestutil.MakeTestEncodingConfig().Codec @@ -188,6 +328,10 @@ func newTestServer(t *testing.T, sk *MockStakingKeeper) (msgServer, Keeper, sdk. cms := integration.CreateMultiStore(keys, logger) authority := authtypes.NewModuleAddress("gov") keeper := NewKeeper(cdc, runtime.NewKVStoreService(keys[types.StoreKey]), sk, nil, nil, authority.String()) + // Default-wire the block ID provider so Attest tests work without extra + // boilerplate. Tests that exercise BlockID-mismatch rejection override + // with their own provider before calling Attest. + keeper.SetBlockIDProvider(staticBlockIDProvider{hash: bytes.Repeat([]byte{0x01}, 32)}) server := msgServer{Keeper: keeper} ctx := sdk.NewContext(cms, cmtproto.Header{ChainID: "test-chain", Time: time.Now().UTC(), Height: 10}, false, logger). WithContext(t.Context()) @@ -195,8 +339,6 @@ func newTestServer(t *testing.T, sk *MockStakingKeeper) (msgServer, Keeper, sdk. } func TestAttestHeightBounds(t *testing.T) { - myValAddr := sdk.ValAddress("validator1") - ownerAddr := sdk.ValAddress("attester_owner") // With DefaultParams: EpochLength=1, PruneAfter=15 // At blockHeight=100: currentEpoch=100, minHeight=(100-7)*1=93 specs := map[string]struct { @@ -243,32 +385,47 @@ func TestAttestHeightBounds(t *testing.T) { } for name, spec := range specs { t.Run(name, func(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + authorityAddr := sdk.AccAddress(pub.Address()).String() + sk := NewMockStakingKeeper() cdc := moduletestutil.MakeTestEncodingConfig().Codec keys := storetypes.NewKVStoreKeys(types.StoreKey) logger := log.NewTestLogger(t) cms := integration.CreateMultiStore(keys, logger) authority := authtypes.NewModuleAddress("gov") - keeper := NewKeeper(cdc, runtime.NewKVStoreService(keys[types.StoreKey]), sk, nil, nil, authority.String()) + keeper := NewKeeper(cdc, runtime.NewKVStoreService(keys[types.StoreKey]), &sk, nil, nil, authority.String()) + blockHash := bytes.Repeat([]byte{0x01}, 32) + keeper.SetBlockIDProvider(staticBlockIDProvider{hash: blockHash}) server := msgServer{Keeper: keeper} ctx := sdk.NewContext(cms, cmtproto.Header{ - ChainID: "test-chain", + ChainID: chainID, Time: time.Now().UTC(), Height: spec.blockHeight, }, false, logger).WithContext(t.Context()) require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) - require.NoError(t, keeper.SetAttesterInfo(ctx, myValAddr.String(), &types.AttesterInfo{Authority: ownerAddr.String()})) - require.NoError(t, keeper.SetAttesterSetMember(ctx, myValAddr.String())) - require.NoError(t, keeper.SetValidatorIndex(ctx, myValAddr.String(), 0, 1)) + // Register the attester directly via keeper (no MsgJoin) + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(authorityAddr, sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + require.NoError(t, keeper.SetAttesterSetMember(ctx, consAddr)) + require.NoError(t, keeper.SetValidatorIndex(ctx, consAddr, 0, 1)) + + // Build a signed vote for the expected height + voteBytes := signTestVote(t, chainID, spec.attestH, priv, blockHash) - // when msg := &types.MsgAttest{ - Authority: ownerAddr.String(), - ConsensusAddress: myValAddr.String(), + Authority: authorityAddr, + ConsensusAddress: consAddr, Height: spec.attestH, - Vote: make([]byte, MinVoteLen), + Vote: voteBytes, } rsp, err := server.Attest(ctx, msg) if spec.expErr != nil { @@ -282,6 +439,32 @@ func TestAttestHeightBounds(t *testing.T) { } } +func TestGetAllSignaturesForHeightUsesValidatorIndexOrder(t *testing.T) { + sk := NewMockStakingKeeper() + _, keeper, ctx := newTestServer(t, &sk) + + const height int64 = 42 + indexZeroAddr := "z-index-zero" + indexOneAddr := "a-index-one" + signature := []byte("signature-for-index-zero") + + require.NoError(t, keeper.SetAttesterSetMember(ctx, indexZeroAddr)) + require.NoError(t, keeper.SetAttesterSetMember(ctx, indexOneAddr)) + require.NoError(t, keeper.SetValidatorIndex(ctx, indexZeroAddr, 0, 1)) + require.NoError(t, keeper.SetValidatorIndex(ctx, indexOneAddr, 1, 1)) + + bitmap := keeper.bitmapHelper.NewBitmap(2) + keeper.bitmapHelper.SetBit(bitmap, 0) + require.NoError(t, keeper.SetAttestationBitmap(ctx, height, bitmap)) + require.NoError(t, keeper.SetSignature(ctx, height, indexZeroAddr, signature)) + + signatures, err := keeper.GetAllSignaturesForHeight(ctx, height) + require.NoError(t, err) + require.Equal(t, map[string][]byte{ + indexZeroAddr: signature, + }, signatures) +} + var _ types.StakingKeeper = &MockStakingKeeper{} type MockStakingKeeper struct { @@ -325,3 +508,376 @@ func (m MockStakingKeeper) GetLastValidators(ctx context.Context) (validators [] func (m MockStakingKeeper) GetLastTotalPower(ctx context.Context) (math.Int, error) { return math.NewInt(int64(len(m.activeSet))), nil } + +func TestVerifyVote(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + // 32-byte block hash (CanonicalizeBlockID requires 32 bytes or empty) + blockHash := bytes.Repeat([]byte{0xbb}, 32) + + sk := NewMockStakingKeeper() + _, keeper, ctx := newTestServer(t, &sk) + // Override the default provider so the "valid" spec's BlockID.Hash + // matches the sequencer's stored hash. + keeper.SetBlockIDProvider(staticBlockIDProvider{hash: blockHash}) + + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + + validBytes := signTestVote(t, chainID, 42, priv, blockHash) + + specs := map[string]struct { + consAddr string + vote []byte + msgH int64 + expErr error + }{ + "valid": { + consAddr: consAddr, + vote: validBytes, + msgH: 42, + }, + "wrong chain id": { + consAddr: consAddr, + vote: signTestVote(t, "other-chain", 42, priv, blockHash), + msgH: 42, + expErr: sdkerrors.ErrUnauthorized, + }, + "wrong height": { + consAddr: consAddr, + vote: validBytes, + msgH: 99, + expErr: sdkerrors.ErrInvalidRequest, + }, + "random 64 bytes": { + consAddr: consAddr, + vote: bytes.Repeat([]byte{0x01}, 64), + msgH: 42, + expErr: sdkerrors.ErrInvalidRequest, // unmarshal may succeed but checks fail + }, + "signed by different key": { + consAddr: consAddr, + vote: signTestVote(t, chainID, 42, cmted25519.GenPrivKey(), blockHash), + msgH: 42, + expErr: sdkerrors.ErrUnauthorized, + }, + "prevote type": { + consAddr: consAddr, + vote: func() []byte { + v := cmtproto.Vote{ + Type: cmtproto.PrevoteType, + Height: 42, + Round: 0, + BlockID: cmtproto.BlockID{Hash: blockHash, PartSetHeader: cmtproto.PartSetHeader{}}, + Timestamp: testTimeUTC(), + ValidatorAddress: pub.Address(), + } + sb := cmttypes.VoteSignBytes(chainID, &v) + sig, _ := priv.Sign(sb) + v.Signature = sig + bz, _ := proto.Marshal(&v) + return bz + }(), + msgH: 42, + expErr: sdkerrors.ErrInvalidRequest, + }, + "non-zero round": { + consAddr: consAddr, + vote: func() []byte { + v := cmtproto.Vote{ + Type: cmtproto.PrecommitType, + Height: 42, + Round: 1, + BlockID: cmtproto.BlockID{Hash: blockHash, PartSetHeader: cmtproto.PartSetHeader{}}, + Timestamp: testTimeUTC(), + ValidatorAddress: pub.Address(), + } + sb := cmttypes.VoteSignBytes(chainID, &v) + sig, _ := priv.Sign(sb) + v.Signature = sig + bz, _ := proto.Marshal(&v) + return bz + }(), + msgH: 42, + expErr: sdkerrors.ErrInvalidRequest, + }, + "unknown consensus address": { + consAddr: sdk.ConsAddress(bytes.Repeat([]byte{0x77}, 20)).String(), + vote: validBytes, + msgH: 42, + expErr: sdkerrors.ErrNotFound, + }, + } + + for name, spec := range specs { + t.Run(name, func(t *testing.T) { + sdkCtx := ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID}) + _, err := keeper.VerifyVoteForTest(sdkCtx, spec.consAddr, spec.vote, spec.msgH) + if spec.expErr != nil { + require.ErrorIs(t, err, spec.expErr) + return + } + require.NoError(t, err) + }) + } +} + +// TestVerifyVote_RejectsMismatchedBlockIDHash is a regression for the +// attester-forged-BlockID vector: the attester produces a self-consistent +// signed vote but over a BlockID.Hash that does not match what the +// sequencer stored for the height. The 07-tendermint light client would +// later reject the reconstructed commit; MsgAttest must fail fast here. +func TestVerifyVote_RejectsMismatchedBlockIDHash(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + sequencerHash := bytes.Repeat([]byte{0xaa}, 32) + forgedHash := bytes.Repeat([]byte{0xff}, 32) + + sk := NewMockStakingKeeper() + _, keeper, ctx := newTestServer(t, &sk) + keeper.SetBlockIDProvider(staticBlockIDProvider{hash: sequencerHash}) + + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + + sdkCtx := ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID}) + + // Attester signs a well-formed vote but over the forged hash. + forgedVote := signTestVote(t, chainID, 42, priv, forgedHash) + _, err = keeper.VerifyVoteForTest(sdkCtx, consAddr, forgedVote, 42) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + require.Contains(t, err.Error(), "does not match sequencer BlockID") + + // Control: the same machinery accepts a vote over the real hash. + realVote := signTestVote(t, chainID, 42, priv, sequencerHash) + _, err = keeper.VerifyVoteForTest(sdkCtx, consAddr, realVote, 42) + require.NoError(t, err) +} + +func TestVerifyVote_RejectsMismatchedBlockIDPartSetHeader(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + blockHash := bytes.Repeat([]byte{0xaa}, 32) + storedPartSetHash := bytes.Repeat([]byte{0x11}, 32) + forgedPartSetHash := bytes.Repeat([]byte{0x22}, 32) + + sk := NewMockStakingKeeper() + _, keeper, ctx := newTestServer(t, &sk) + keeper.SetBlockIDProvider(staticBlockIDProvider{ + hash: blockHash, + partSetHeader: cmttypes.PartSetHeader{ + Total: 1, + Hash: storedPartSetHash, + }, + }) + + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + + sdkCtx := ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID}) + forgedBlockID := cmtproto.BlockID{ + Hash: blockHash, + PartSetHeader: cmtproto.PartSetHeader{ + Total: 1, + Hash: forgedPartSetHash, + }, + } + forgedVote := signTestVoteWithBlockID(t, chainID, 42, priv, forgedBlockID) + + _, err = keeper.VerifyVoteForTest(sdkCtx, consAddr, forgedVote, 42) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + require.Contains(t, err.Error(), "does not match sequencer BlockID") +} + +func TestVerifyVote_RejectsBlockIDProviderError(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + blockHash := bytes.Repeat([]byte{0x01}, 32) + + sk := NewMockStakingKeeper() + _, keeper, ctx := newTestServer(t, &sk) + keeper.SetBlockIDProvider(failingBlockIDProvider{err: errors.New("store unavailable")}) + + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + + sdkCtx := ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID}) + _, err = keeper.VerifyVoteForTest(sdkCtx, consAddr, signTestVote(t, chainID, 10, priv, blockHash), 10) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + require.Contains(t, err.Error(), "get block ID for height 10") + require.Contains(t, err.Error(), "store unavailable") +} + +func TestVerifyVote_RejectsNilProviderBlockID(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + blockHash := bytes.Repeat([]byte{0x01}, 32) + + sk := NewMockStakingKeeper() + _, keeper, ctx := newTestServer(t, &sk) + keeper.SetBlockIDProvider(nilBlockIDProvider{}) + + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + + sdkCtx := ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID}) + _, err = keeper.VerifyVoteForTest(sdkCtx, consAddr, signTestVote(t, chainID, 10, priv, blockHash), 10) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + require.Contains(t, err.Error(), "block ID for height 10 not found") +} + +func TestVerifyVote_RejectsMalformedBlockID(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + + sk := NewMockStakingKeeper() + _, keeper, ctx := newTestServer(t, &sk) + + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + + malformedBlockID := cmtproto.BlockID{ + Hash: bytes.Repeat([]byte{0x01}, 31), + PartSetHeader: cmtproto.PartSetHeader{ + Total: 1, + Hash: bytes.Repeat([]byte{0x02}, 32), + }, + } + vote := cmtproto.Vote{ + Type: cmtproto.PrecommitType, + Height: 10, + Round: 0, + BlockID: malformedBlockID, + Timestamp: testTimeUTC(), + ValidatorAddress: pub.Address(), + ValidatorIndex: 0, + Signature: bytes.Repeat([]byte{0x03}, 64), + } + voteBytes, err := proto.Marshal(&vote) + require.NoError(t, err) + + sdkCtx := ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID}) + _, err = keeper.VerifyVoteForTest(sdkCtx, consAddr, voteBytes, 10) + require.ErrorIs(t, err, sdkerrors.ErrInvalidRequest) + require.Contains(t, err.Error(), "invalid vote BlockID") +} + +// TestVerifyVote_RejectsUnwiredProvider guards against a misconfigured app +// where SetBlockIDProvider is never called — MsgAttest must fail closed +// rather than silently accept every vote. +func TestVerifyVote_RejectsUnwiredProvider(t *testing.T) { + chainID := "test-chain" + priv := cmted25519.GenPrivKey() + pub := priv.PubKey().(cmted25519.PubKey) + consAddr := sdk.ConsAddress(pub.Address()).String() + + sk := NewMockStakingKeeper() + cdc := moduletestutil.MakeTestEncodingConfig().Codec + keys := storetypes.NewKVStoreKeys(types.StoreKey) + logger := log.NewTestLogger(t) + cms := integration.CreateMultiStore(keys, logger) + authority := authtypes.NewModuleAddress("gov") + // Intentionally do NOT call SetBlockIDProvider. + keeper := NewKeeper(cdc, runtime.NewKVStoreService(keys[types.StoreKey]), &sk, nil, nil, authority.String()) + ctx := sdk.NewContext(cms, cmtproto.Header{ChainID: chainID, Time: time.Now().UTC(), Height: 10}, false, logger). + WithContext(t.Context()) + + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + info, err := types.NewAttesterInfo(sdk.AccAddress(pub.Address()).String(), sdkPk, 0) + require.NoError(t, err) + require.NoError(t, keeper.SetAttesterInfo(ctx, consAddr, info)) + + voteBytes := signTestVote(t, chainID, 10, priv, bytes.Repeat([]byte{0x01}, 32)) + sdkCtx := ctx.WithBlockHeader(cmtproto.Header{ChainID: chainID}) + + _, err = keeper.VerifyVoteForTest(sdkCtx, consAddr, voteBytes, 10) + require.Error(t, err) + require.Contains(t, err.Error(), "provider not wired") +} + +func registerTestAttester( + ctx sdk.Context, + keeper *Keeper, + authorityAddr string, + consAddr string, + pub cmted25519.PubKey, + index uint16, +) error { + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + if err != nil { + return fmt.Errorf("convert consensus pubkey: %w", err) + } + info, err := types.NewAttesterInfo(authorityAddr, sdkPk, 0) + if err != nil { + return fmt.Errorf("create attester info: %w", err) + } + if err := keeper.SetAttesterInfo(ctx, consAddr, info); err != nil { + return fmt.Errorf("set attester info: %w", err) + } + if err := keeper.SetAttesterSetMember(ctx, consAddr); err != nil { + return fmt.Errorf("set attester set member: %w", err) + } + if err := keeper.SetValidatorIndex(ctx, consAddr, index, 1); err != nil { + return fmt.Errorf("set validator index: %w", err) + } + return nil +} + +func signTestVoteAt( + t *testing.T, + chainID string, + height int64, + priv cmted25519.PrivKey, + blockIDHash []byte, + timestamp time.Time, +) []byte { + t.Helper() + pub := priv.PubKey().(cmted25519.PubKey) + v := cmtproto.Vote{ + Type: cmtproto.PrecommitType, + Height: height, + Round: 0, + BlockID: cmtproto.BlockID{Hash: blockIDHash, PartSetHeader: cmtproto.PartSetHeader{}}, + Timestamp: timestamp, + ValidatorAddress: pub.Address(), + ValidatorIndex: 0, + } + sb := cmttypes.VoteSignBytes(chainID, &v) + sig, err := priv.Sign(sb) + require.NoError(t, err) + v.Signature = sig + out, err := proto.Marshal(&v) + require.NoError(t, err) + return out +} diff --git a/modules/network/keeper/testhelpers_test.go b/modules/network/keeper/testhelpers_test.go new file mode 100644 index 00000000..72958926 --- /dev/null +++ b/modules/network/keeper/testhelpers_test.go @@ -0,0 +1,67 @@ +package keeper + +import ( + "bytes" + "context" + "testing" + "time" + + cmted25519 "github.com/cometbft/cometbft/crypto/ed25519" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cmttypes "github.com/cometbft/cometbft/types" + "github.com/cosmos/gogoproto/proto" + "github.com/stretchr/testify/require" +) + +// staticBlockIDProvider is a test double returning the same BlockID regardless +// of height. Mirrors the sequencer's view of a stored block hash. +type staticBlockIDProvider struct { + hash []byte + partSetHeader cmttypes.PartSetHeader +} + +func (s staticBlockIDProvider) GetBlockID(_ context.Context, _ uint64) (*cmttypes.BlockID, error) { + return &cmttypes.BlockID{Hash: s.hash, PartSetHeader: s.partSetHeader}, nil +} + +// signTestVote builds a cmtproto.Vote for the given height and key and returns +// the protobuf-marshaled bytes with the signature attached. +func signTestVote(t *testing.T, chainID string, height int64, priv cmted25519.PrivKey, blockIDHash []byte) []byte { + t.Helper() + return signTestVoteWithBlockID(t, chainID, height, priv, cmtproto.BlockID{ + Hash: blockIDHash, + PartSetHeader: cmtproto.PartSetHeader{}, + }) +} + +func signTestVoteWithBlockID(t *testing.T, chainID string, height int64, priv cmted25519.PrivKey, blockID cmtproto.BlockID) []byte { + t.Helper() + pub := priv.PubKey().(cmted25519.PubKey) + v := cmtproto.Vote{ + Type: cmtproto.PrecommitType, + Height: height, + Round: 0, + BlockID: blockID, + Timestamp: testTimeUTC(), + ValidatorAddress: pub.Address(), + ValidatorIndex: 0, + } + sb := cmttypes.VoteSignBytes(chainID, &v) + sig, err := priv.Sign(sb) + require.NoError(t, err) + v.Signature = sig + out, err := proto.Marshal(&v) + require.NoError(t, err) + return out +} + +// testTimeUTC returns a fixed deterministic time for vote timestamps. +func testTimeUTC() time.Time { + return time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC) +} + +func TestSignTestVoteCompiles(t *testing.T) { + priv := cmted25519.GenPrivKey() + bz := signTestVote(t, "chain", 10, priv, bytes.Repeat([]byte{0xab}, 32)) + require.NotEmpty(t, bz) +} diff --git a/modules/network/types/expected_keepers.go b/modules/network/types/expected_keepers.go index e769df9c..cc1a23bf 100644 --- a/modules/network/types/expected_keepers.go +++ b/modules/network/types/expected_keepers.go @@ -4,6 +4,7 @@ import ( "context" "cosmossdk.io/math" + cmttypes "github.com/cometbft/cometbft/types" sdk "github.com/cosmos/cosmos-sdk/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" @@ -32,3 +33,9 @@ type BankKeeper interface { type BlockSource interface { GetBlockData(ctx context.Context, height uint64) (*tyrollkittypes.SignedHeader, *tyrollkittypes.Data, error) } + +// BlockIDProvider returns the canonical CometBFT BlockID for a given rollkit height. +// Used by the network module to pin attester votes to the sequencer's block hash. +type BlockIDProvider interface { + GetBlockID(ctx context.Context, height uint64) (*cmttypes.BlockID, error) +} diff --git a/server/start.go b/server/start.go index 53ef9bf7..194e2fc0 100644 --- a/server/start.go +++ b/server/start.go @@ -54,6 +54,7 @@ import ( "github.com/evstack/ev-node/pkg/store" rollkittypes "github.com/evstack/ev-node/types" + "github.com/evstack/ev-abci/modules/network/types" "github.com/evstack/ev-abci/pkg/adapter" "github.com/evstack/ev-abci/pkg/rpc" "github.com/evstack/ev-abci/pkg/rpc/core" @@ -61,6 +62,14 @@ import ( execstore "github.com/evstack/ev-abci/pkg/store" ) +// networkKeeperBlockIDWirer is the minimal interface an application must +// expose so the ev-abci server can attach the adapter block store to the +// network module's keeper. Applications satisfy it by declaring a method +// that accepts the BlockIDProvider and forwards it to the network keeper. +type networkKeeperBlockIDWirer interface { + SetNetworkKeeperBlockIDProvider(types.BlockIDProvider) +} + const ( flagTraceStore = "trace-store" flagGRPCOnly = "grpc-only" @@ -436,6 +445,14 @@ func setupNodeAndExecutor( opts..., ) + // Give the network module's MsgAttest handler access to the adapter's + // block store so it can pin each vote to the sequencer's real BlockID. + if w, ok := app.(networkKeeperBlockIDWirer); ok { + w.SetNetworkKeeperBlockIDProvider(executor.Store) + } else { + sdkLogger.Warn("app does not implement networkKeeperBlockIDWirer; MsgAttest will reject votes if attester mode is enabled") + } + cmtApp := sdkserver.NewCometABCIWrapper(app) clientCreator := proxy.NewLocalClientCreator(cmtApp) From 7631cef8eca05f91082e307b73350ec9d5db38df Mon Sep 17 00:00:00 2001 From: Randy Grok <98407738+randygrok@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:52:48 +0200 Subject: [PATCH 5/9] feat(rpc): reconstruct attester commits deterministically (Part 3) (#396) * feat(network): verify attester votes * feat(rpc): reconstruct attester commits deterministically --- modules/network/keeper/grpc_query.go | 28 + modules/network/types/query.pb.go | 777 ++++++++++++++++++-- modules/network/types/query.pb.gw.go | 65 ++ modules/proto/evabci/network/v1/query.proto | 25 + pkg/adapter/providers_test.go | 66 ++ pkg/rpc/core/blocks.go | 124 ++-- pkg/rpc/core/commit_reconstruction_test.go | 295 ++++++++ pkg/rpc/core/consensus.go | 162 +--- pkg/rpc/core/consensus_test.go | 106 +++ 9 files changed, 1402 insertions(+), 246 deletions(-) create mode 100644 pkg/adapter/providers_test.go create mode 100644 pkg/rpc/core/commit_reconstruction_test.go diff --git a/modules/network/keeper/grpc_query.go b/modules/network/keeper/grpc_query.go index 3c9f91e4..763f3896 100644 --- a/modules/network/keeper/grpc_query.go +++ b/modules/network/keeper/grpc_query.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "sort" "cosmossdk.io/collections" sdk "github.com/cosmos/cosmos-sdk/types" @@ -237,6 +238,33 @@ func (q *queryServer) LastAttestedHeight(c context.Context, req *types.QueryLast }, nil } +// AttesterSet queries the full ordered attester set +func (q *queryServer) AttesterSet(goCtx context.Context, req *types.QueryAttesterSetRequest) (*types.QueryAttesterSetResponse, error) { + if req == nil { + return nil, status.Error(codes.InvalidArgument, "invalid request") + } + + ctx := sdk.UnwrapSDKContext(goCtx) + entries := []types.AttesterSetEntry{} + if err := q.keeper.ValidatorIndex.Walk(ctx, nil, func(addr string, idx uint16) (bool, error) { + info, err := q.keeper.GetAttesterInfo(ctx, addr) + if err != nil { + return false, err + } + entries = append(entries, types.AttesterSetEntry{ + Authority: info.Authority, + ConsensusAddress: addr, + Index: uint32(idx), + Pubkey: info.Pubkey, + }) + return false, nil + }); err != nil { + return nil, err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Index < entries[j].Index }) + return &types.QueryAttesterSetResponse{Entries: entries}, nil +} + // AttesterInfo queries the attester information including public key func (q *queryServer) AttesterInfo(c context.Context, req *types.QueryAttesterInfoRequest) (*types.QueryAttesterInfoResponse, error) { if req == nil { diff --git a/modules/network/types/query.pb.go b/modules/network/types/query.pb.go index 3b389163..7c788d82 100644 --- a/modules/network/types/query.pb.go +++ b/modules/network/types/query.pb.go @@ -6,6 +6,8 @@ package types import ( context "context" fmt "fmt" + _ "github.com/cosmos/cosmos-proto" + types "github.com/cosmos/cosmos-sdk/codec/types" _ "github.com/cosmos/cosmos-sdk/types/query" _ "github.com/cosmos/gogoproto/gogoproto" grpc1 "github.com/cosmos/gogoproto/grpc" @@ -852,6 +854,129 @@ func (m *QueryAttesterInfoResponse) GetAttesterInfo() *AttesterInfo { return nil } +// QueryAttesterSetRequest is the request type for the Query/AttesterSet RPC method. +type QueryAttesterSetRequest struct { +} + +func (m *QueryAttesterSetRequest) Reset() { *m = QueryAttesterSetRequest{} } +func (m *QueryAttesterSetRequest) String() string { return proto.CompactTextString(m) } +func (*QueryAttesterSetRequest) ProtoMessage() {} +func (*QueryAttesterSetRequest) Descriptor() ([]byte, []int) { + return fileDescriptor_faab6bfc228a74e1, []int{17} +} +func (m *QueryAttesterSetRequest) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *QueryAttesterSetRequest) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_QueryAttesterSetRequest.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *QueryAttesterSetRequest) XXX_Merge(src proto.Message) { + xxx_messageInfo_QueryAttesterSetRequest.Merge(m, src) +} +func (m *QueryAttesterSetRequest) XXX_Size() int { + return m.Size() +} +func (m *QueryAttesterSetRequest) XXX_DiscardUnknown() { + xxx_messageInfo_QueryAttesterSetRequest.DiscardUnknown(m) +} + +var xxx_messageInfo_QueryAttesterSetRequest proto.InternalMessageInfo + +// AttesterSetEntry is a single entry in the attester set, ordered by index. +type AttesterSetEntry struct { + Authority string `protobuf:"bytes,1,opt,name=authority,proto3" json:"authority,omitempty"` + ConsensusAddress string `protobuf:"bytes,2,opt,name=consensus_address,json=consensusAddress,proto3" json:"consensus_address,omitempty"` + Index uint32 `protobuf:"varint,3,opt,name=index,proto3" json:"index,omitempty"` + Pubkey *types.Any `protobuf:"bytes,4,opt,name=pubkey,proto3" json:"pubkey,omitempty"` +} + +func (m *AttesterSetEntry) Reset() { *m = AttesterSetEntry{} } +func (m *AttesterSetEntry) String() string { return proto.CompactTextString(m) } +func (*AttesterSetEntry) ProtoMessage() {} +func (*AttesterSetEntry) Descriptor() ([]byte, []int) { + return fileDescriptor_faab6bfc228a74e1, []int{18} +} +func (m *AttesterSetEntry) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *AttesterSetEntry) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_AttesterSetEntry.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *AttesterSetEntry) XXX_Merge(src proto.Message) { + xxx_messageInfo_AttesterSetEntry.Merge(m, src) +} +func (m *AttesterSetEntry) XXX_Size() int { + return m.Size() +} +func (m *AttesterSetEntry) XXX_DiscardUnknown() { + xxx_messageInfo_AttesterSetEntry.DiscardUnknown(m) +} + +var xxx_messageInfo_AttesterSetEntry proto.InternalMessageInfo + +// QueryAttesterSetResponse is the response type for the Query/AttesterSet RPC method. +type QueryAttesterSetResponse struct { + Entries []AttesterSetEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries"` +} + +func (m *QueryAttesterSetResponse) Reset() { *m = QueryAttesterSetResponse{} } +func (m *QueryAttesterSetResponse) String() string { return proto.CompactTextString(m) } +func (*QueryAttesterSetResponse) ProtoMessage() {} +func (*QueryAttesterSetResponse) Descriptor() ([]byte, []int) { + return fileDescriptor_faab6bfc228a74e1, []int{19} +} +func (m *QueryAttesterSetResponse) XXX_Unmarshal(b []byte) error { + return m.Unmarshal(b) +} +func (m *QueryAttesterSetResponse) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { + if deterministic { + return xxx_messageInfo_QueryAttesterSetResponse.Marshal(b, m, deterministic) + } else { + b = b[:cap(b)] + n, err := m.MarshalToSizedBuffer(b) + if err != nil { + return nil, err + } + return b[:n], nil + } +} +func (m *QueryAttesterSetResponse) XXX_Merge(src proto.Message) { + xxx_messageInfo_QueryAttesterSetResponse.Merge(m, src) +} +func (m *QueryAttesterSetResponse) XXX_Size() int { + return m.Size() +} +func (m *QueryAttesterSetResponse) XXX_DiscardUnknown() { + xxx_messageInfo_QueryAttesterSetResponse.DiscardUnknown(m) +} + +var xxx_messageInfo_QueryAttesterSetResponse proto.InternalMessageInfo + +func (m *QueryAttesterSetResponse) GetEntries() []AttesterSetEntry { + if m != nil { + return m.Entries + } + return nil +} + func init() { proto.RegisterType((*QueryParamsRequest)(nil), "evabci.network.v1.QueryParamsRequest") proto.RegisterType((*QueryParamsResponse)(nil), "evabci.network.v1.QueryParamsResponse") @@ -870,77 +995,94 @@ func init() { proto.RegisterType((*QueryLastAttestedHeightResponse)(nil), "evabci.network.v1.QueryLastAttestedHeightResponse") proto.RegisterType((*QueryAttesterInfoRequest)(nil), "evabci.network.v1.QueryAttesterInfoRequest") proto.RegisterType((*QueryAttesterInfoResponse)(nil), "evabci.network.v1.QueryAttesterInfoResponse") + proto.RegisterType((*QueryAttesterSetRequest)(nil), "evabci.network.v1.QueryAttesterSetRequest") + proto.RegisterType((*AttesterSetEntry)(nil), "evabci.network.v1.AttesterSetEntry") + proto.RegisterType((*QueryAttesterSetResponse)(nil), "evabci.network.v1.QueryAttesterSetResponse") } func init() { proto.RegisterFile("evabci/network/v1/query.proto", fileDescriptor_faab6bfc228a74e1) } var fileDescriptor_faab6bfc228a74e1 = []byte{ - // 1040 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x56, 0xcf, 0x6f, 0x1b, 0x45, - 0x14, 0xce, 0x36, 0x89, 0x21, 0x2f, 0xa1, 0x6d, 0xa6, 0x21, 0x24, 0x6e, 0xe3, 0x38, 0x0b, 0xb4, - 0x6e, 0x8b, 0x3d, 0x75, 0x50, 0x1b, 0x2a, 0xf5, 0xd2, 0xf0, 0xb3, 0x08, 0xa1, 0xb2, 0x91, 0x7a, - 0xe0, 0x80, 0x35, 0xf6, 0x8e, 0x37, 0xab, 0xda, 0x3b, 0x9b, 0x9d, 0xb1, 0x4b, 0x89, 0x72, 0xe1, - 0x08, 0x17, 0x24, 0xfe, 0x00, 0x04, 0x37, 0x04, 0x7f, 0x02, 0xe2, 0xdc, 0x63, 0x25, 0x2e, 0x9c, - 0x10, 0x4a, 0xf8, 0x43, 0xd0, 0xbe, 0x19, 0xaf, 0xd7, 0xf1, 0xae, 0x9d, 0x9c, 0x92, 0x7d, 0xef, - 0xfb, 0xde, 0xfb, 0xe6, 0xcd, 0xcc, 0x37, 0x86, 0x0d, 0xde, 0x67, 0xcd, 0x96, 0x4f, 0x03, 0xae, - 0x9e, 0x89, 0xe8, 0x29, 0xed, 0xd7, 0xe9, 0x41, 0x8f, 0x47, 0xcf, 0x6b, 0x61, 0x24, 0x94, 0x20, - 0xcb, 0x3a, 0x5d, 0x33, 0xe9, 0x5a, 0xbf, 0x5e, 0x5c, 0xf1, 0x84, 0x27, 0x30, 0x4b, 0xe3, 0xff, - 0x34, 0xb0, 0x78, 0xcd, 0x13, 0xc2, 0xeb, 0x70, 0xca, 0x42, 0x9f, 0xb2, 0x20, 0x10, 0x8a, 0x29, - 0x5f, 0x04, 0xd2, 0x64, 0x6f, 0xb5, 0x84, 0xec, 0x0a, 0x49, 0x9b, 0x4c, 0x72, 0x5d, 0x9f, 0xf6, - 0xeb, 0x4d, 0xae, 0x58, 0x9d, 0x86, 0xcc, 0xf3, 0x03, 0x04, 0x1b, 0x6c, 0x86, 0x22, 0xf5, 0x3c, - 0xe4, 0x83, 0x52, 0xe5, 0xf1, 0x34, 0x53, 0x8a, 0x4b, 0xc5, 0x23, 0x8d, 0xb0, 0x57, 0x80, 0x7c, - 0x11, 0xb7, 0x78, 0xcc, 0x22, 0xd6, 0x95, 0x0e, 0x3f, 0xe8, 0x71, 0xa9, 0xec, 0xcf, 0xe1, 0xca, - 0x48, 0x54, 0x86, 0x22, 0x90, 0x9c, 0xec, 0x40, 0x21, 0xc4, 0xc8, 0x9a, 0x55, 0xb6, 0x2a, 0x8b, - 0xdb, 0xeb, 0xb5, 0xb1, 0x15, 0xd7, 0x34, 0x65, 0x77, 0xee, 0xc5, 0x3f, 0x9b, 0x33, 0x8e, 0x81, - 0xdb, 0x3b, 0xb0, 0x81, 0xf5, 0x1e, 0x62, 0x73, 0x5c, 0xc0, 0xae, 0xaf, 0xba, 0x2c, 0x34, 0x0d, - 0xc9, 0x2a, 0x14, 0xf6, 0xb9, 0xef, 0xed, 0x2b, 0xac, 0x3c, 0xeb, 0x98, 0x2f, 0xfb, 0x2b, 0x28, - 0xe5, 0x11, 0x8d, 0xa6, 0x07, 0x50, 0x68, 0x62, 0xc4, 0x68, 0x7a, 0x2b, 0x43, 0xd3, 0x38, 0xdb, - 0x70, 0xec, 0x2a, 0xbc, 0x8e, 0xf5, 0x3f, 0x0c, 0x45, 0x6b, 0xff, 0x51, 0xd0, 0x16, 0x03, 0x41, - 0x2b, 0x30, 0xcf, 0xe3, 0x18, 0x56, 0x9d, 0x73, 0xf4, 0x87, 0xfd, 0xfd, 0x05, 0x58, 0x3d, 0x8d, - 0x37, 0x3a, 0x32, 0x09, 0x64, 0x0b, 0x96, 0xa4, 0x62, 0x91, 0x6a, 0x98, 0xd5, 0x5d, 0xc0, 0xd5, - 0x2d, 0x62, 0xec, 0x13, 0x0c, 0x91, 0x0d, 0x00, 0x1e, 0xb8, 0x03, 0xc0, 0x2c, 0x02, 0x16, 0x78, - 0xe0, 0x9a, 0x74, 0x1d, 0x56, 0x42, 0x16, 0x29, 0xbf, 0xe5, 0x87, 0xb8, 0x80, 0x86, 0x59, 0xed, - 0x5c, 0xd9, 0xaa, 0x2c, 0x39, 0x57, 0x46, 0x72, 0x7a, 0x71, 0xe4, 0x36, 0x2c, 0xb3, 0x96, 0xf2, - 0xfb, 0xbc, 0xd1, 0x67, 0x1d, 0xdf, 0x65, 0x4a, 0x44, 0x72, 0x6d, 0x1e, 0x65, 0x5d, 0xd6, 0x89, - 0x27, 0x49, 0x9c, 0xdc, 0x87, 0xb5, 0x54, 0x8d, 0xc0, 0x4b, 0x73, 0x0a, 0xc8, 0x79, 0x63, 0x24, - 0x3f, 0xa4, 0xda, 0xf7, 0xa0, 0x88, 0xc3, 0x48, 0x42, 0x8f, 0x02, 0x97, 0x7f, 0x3d, 0x98, 0xe0, - 0x1a, 0xbc, 0xc2, 0x5c, 0x37, 0xe2, 0x52, 0x9f, 0x96, 0x05, 0x67, 0xf0, 0x69, 0x3f, 0x81, 0xab, - 0x99, 0xbc, 0xe4, 0x94, 0xcd, 0xfb, 0x71, 0xc0, 0x6c, 0xe8, 0x56, 0xc6, 0x86, 0x9e, 0x62, 0x6a, - 0xbc, 0xfd, 0x00, 0x6c, 0xac, 0xbb, 0x27, 0xda, 0xea, 0x7d, 0x11, 0xb4, 0xfd, 0xa8, 0x8b, 0x63, - 0xd9, 0x53, 0x4c, 0xf5, 0xe4, 0xb4, 0xa3, 0xf6, 0x87, 0x05, 0x6f, 0x4e, 0xa4, 0x1b, 0x79, 0xb7, - 0x60, 0xd9, 0x97, 0x0d, 0x29, 0xda, 0xaa, 0xd1, 0xd2, 0x28, 0xee, 0x62, 0xa9, 0x57, 0x9d, 0x4b, - 0xbe, 0x4c, 0x91, 0xb9, 0x4b, 0x36, 0x61, 0xb1, 0x2f, 0x14, 0x77, 0x1b, 0xa1, 0x78, 0xc6, 0x23, - 0xdc, 0xfd, 0x39, 0x07, 0x30, 0xf4, 0x38, 0x8e, 0xc4, 0x00, 0x25, 0x14, 0xeb, 0x18, 0xc0, 0xac, - 0x06, 0x60, 0x48, 0x03, 0x6e, 0xc0, 0xa5, 0x83, 0x9e, 0x88, 0x7a, 0xdd, 0x46, 0x3b, 0x8a, 0xf7, - 0x4e, 0x04, 0xb8, 0xf3, 0x0b, 0xce, 0x45, 0x1d, 0xfe, 0xc8, 0x44, 0xed, 0xf7, 0x46, 0x6e, 0x0a, - 0x8f, 0xf6, 0x7c, 0x2f, 0x60, 0xaa, 0x17, 0x71, 0x39, 0xfd, 0x8e, 0x2d, 0x8f, 0x91, 0xe2, 0x33, - 0x94, 0x1c, 0x84, 0xc6, 0xe8, 0x3e, 0x5e, 0x4e, 0x12, 0x0f, 0x75, 0x9c, 0x5c, 0x83, 0x05, 0x39, - 0x60, 0xe2, 0x22, 0x97, 0x9c, 0x61, 0xc0, 0xf6, 0x60, 0x33, 0x57, 0x99, 0x99, 0xe9, 0x07, 0x00, - 0x09, 0x3e, 0x6e, 0x33, 0x3b, 0xf1, 0x22, 0xa7, 0x4a, 0x38, 0x29, 0x9e, 0x5d, 0x36, 0x23, 0xf8, - 0x8c, 0x49, 0x65, 0x90, 0xe6, 0x16, 0x0d, 0x7c, 0xed, 0xbe, 0x91, 0x92, 0x85, 0x30, 0x52, 0xf2, - 0xa6, 0xf4, 0x31, 0xac, 0x8d, 0xac, 0x22, 0x6d, 0x16, 0xe7, 0x19, 0x96, 0xcd, 0x60, 0x3d, 0xa3, - 0x50, 0x32, 0x88, 0xd7, 0x06, 0x06, 0xdd, 0xf0, 0x83, 0xb6, 0x30, 0x77, 0x60, 0x73, 0xc2, 0x2c, - 0x90, 0xbf, 0xc4, 0x52, 0x5f, 0xdb, 0x3f, 0x03, 0xcc, 0x63, 0x0f, 0xf2, 0x0d, 0x14, 0xb4, 0x21, - 0x93, 0xb7, 0x33, 0x4a, 0x8c, 0x3b, 0x7f, 0xf1, 0xfa, 0x34, 0x98, 0x16, 0x6a, 0x6f, 0x7d, 0xfb, - 0xd7, 0x7f, 0x3f, 0x5e, 0xb8, 0x4a, 0xd6, 0xe9, 0xf8, 0x13, 0xa3, 0x4d, 0x9f, 0xfc, 0x6a, 0x0d, - 0x0e, 0x56, 0xda, 0x9c, 0xee, 0xe4, 0x35, 0xc8, 0x7b, 0x1b, 0x8a, 0xf5, 0x73, 0x30, 0x8c, 0x3a, - 0x8a, 0xea, 0x6e, 0x92, 0x1b, 0x34, 0xef, 0x01, 0x44, 0x16, 0x3d, 0xd4, 0x9b, 0x7b, 0x44, 0xbe, - 0xb3, 0x60, 0x21, 0xf1, 0x74, 0x52, 0xc9, 0xeb, 0x78, 0xfa, 0x99, 0x28, 0xde, 0x3c, 0x03, 0xd2, - 0x68, 0xaa, 0xa0, 0x26, 0x9b, 0x94, 0x33, 0x34, 0xe1, 0x63, 0x41, 0x0f, 0xf1, 0xcf, 0x11, 0xf9, - 0xc9, 0x82, 0x8b, 0xa3, 0x0e, 0x47, 0xaa, 0x79, 0x7d, 0x32, 0xbd, 0xb7, 0x58, 0x3b, 0x2b, 0xdc, - 0x68, 0xab, 0xa1, 0xb6, 0x0a, 0xb9, 0x9e, 0xa1, 0x2d, 0x39, 0xc0, 0xf4, 0xd0, 0x1c, 0xed, 0x23, - 0xf2, 0xa7, 0x05, 0xab, 0xd9, 0x36, 0x49, 0xee, 0xe6, 0xb5, 0x9e, 0xe8, 0xca, 0xc5, 0x7b, 0xe7, - 0xa5, 0x19, 0xe5, 0x77, 0x51, 0x39, 0x25, 0xd5, 0x0c, 0xe5, 0xb1, 0x47, 0x57, 0x5b, 0x29, 0xee, - 0x70, 0xbf, 0x7f, 0xb3, 0x80, 0x8c, 0xfb, 0x11, 0x99, 0x72, 0xd4, 0x32, 0x5c, 0xb5, 0xb8, 0x7d, - 0x1e, 0xca, 0x19, 0xc6, 0x3d, 0xf4, 0xb3, 0xa1, 0xda, 0xdf, 0x2d, 0x20, 0xe3, 0x96, 0x95, 0xaf, - 0x36, 0xd7, 0x00, 0xf3, 0xd5, 0xe6, 0x3b, 0xe2, 0xc4, 0xcb, 0xd4, 0x61, 0x52, 0x55, 0x8d, 0xf7, - 0xb8, 0x55, 0xad, 0x97, 0xfc, 0x62, 0xc1, 0x52, 0xda, 0x9d, 0xc8, 0xed, 0x69, 0x33, 0x4a, 0x5f, - 0xa9, 0x77, 0xce, 0x06, 0x36, 0xe2, 0x76, 0x50, 0x5c, 0x9d, 0x50, 0x9a, 0xff, 0x53, 0x97, 0x1e, - 0x8e, 0xb9, 0xf3, 0xd1, 0xee, 0xa7, 0x2f, 0x8e, 0x4b, 0xd6, 0xcb, 0xe3, 0x92, 0xf5, 0xef, 0x71, - 0xc9, 0xfa, 0xe1, 0xa4, 0x34, 0xf3, 0xf2, 0xa4, 0x34, 0xf3, 0xf7, 0x49, 0x69, 0xe6, 0xcb, 0x3b, - 0x9e, 0xaf, 0xf6, 0x7b, 0xcd, 0x5a, 0x4b, 0x74, 0x29, 0xef, 0x4b, 0xc5, 0x5a, 0x4f, 0x29, 0xef, - 0x57, 0xb1, 0x7a, 0x57, 0xb8, 0xbd, 0x0e, 0x97, 0x49, 0x17, 0xfc, 0xb1, 0xdd, 0x2c, 0xe0, 0x6f, - 0xe9, 0x77, 0xff, 0x0f, 0x00, 0x00, 0xff, 0xff, 0x3d, 0xe0, 0x17, 0xeb, 0x20, 0x0c, 0x00, 0x00, + // 1253 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x57, 0xcf, 0x6f, 0x1b, 0xc5, + 0x17, 0xf7, 0xe6, 0x87, 0x5b, 0x3f, 0xa7, 0x6d, 0x32, 0xf5, 0xb7, 0x75, 0xdc, 0xd6, 0x4e, 0xb6, + 0x5f, 0xda, 0xb4, 0xc5, 0xbb, 0x75, 0x50, 0x5b, 0x8a, 0x7a, 0x69, 0x4a, 0x0b, 0x05, 0x84, 0xca, + 0x46, 0xea, 0x81, 0x03, 0xab, 0xb1, 0x3d, 0x76, 0x56, 0x8d, 0x77, 0xb6, 0x3b, 0xb3, 0x2e, 0x26, + 0xca, 0x85, 0x13, 0x82, 0x03, 0x48, 0xfc, 0x01, 0x54, 0xdc, 0x10, 0xdc, 0xe0, 0x88, 0x38, 0x57, + 0x9c, 0x2a, 0xb8, 0x70, 0x42, 0x28, 0xe1, 0xc0, 0xbf, 0xc0, 0x0d, 0xed, 0xcc, 0xec, 0x7a, 0x1d, + 0xef, 0xda, 0xc9, 0xa9, 0x9d, 0xf7, 0x3e, 0x9f, 0x37, 0x9f, 0x79, 0x33, 0x6f, 0x3f, 0x31, 0x5c, + 0x20, 0x7d, 0xdc, 0x6c, 0x39, 0xa6, 0x4b, 0xf8, 0x33, 0xea, 0x3f, 0x31, 0xfb, 0x0d, 0xf3, 0x69, + 0x40, 0xfc, 0x81, 0xe1, 0xf9, 0x94, 0x53, 0xb4, 0x24, 0xd3, 0x86, 0x4a, 0x1b, 0xfd, 0x46, 0xa5, + 0xd4, 0xa5, 0x5d, 0x2a, 0xb2, 0x66, 0xf8, 0x3f, 0x09, 0xac, 0x9c, 0xef, 0x52, 0xda, 0xdd, 0x26, + 0x26, 0xf6, 0x1c, 0x13, 0xbb, 0x2e, 0xe5, 0x98, 0x3b, 0xd4, 0x65, 0x2a, 0xbb, 0xac, 0xb2, 0x62, + 0xd5, 0x0c, 0x3a, 0x26, 0x76, 0x07, 0x51, 0xaa, 0x45, 0x59, 0x8f, 0x32, 0x5b, 0x56, 0x94, 0x0b, + 0x95, 0xba, 0x2a, 0x57, 0x66, 0x13, 0x33, 0x22, 0x55, 0x99, 0xfd, 0x46, 0x93, 0x70, 0xdc, 0x30, + 0x3d, 0xdc, 0x75, 0x5c, 0xb1, 0x85, 0xc2, 0xa6, 0x9c, 0x83, 0x0f, 0x3c, 0x12, 0x95, 0x5a, 0x19, + 0x4f, 0x63, 0xce, 0x09, 0xe3, 0xc4, 0x97, 0x08, 0xbd, 0x04, 0xe8, 0x83, 0x70, 0x8b, 0x47, 0xd8, + 0xc7, 0x3d, 0x66, 0x91, 0xa7, 0x01, 0x61, 0x5c, 0x7f, 0x1f, 0x4e, 0x8f, 0x44, 0x99, 0x47, 0x5d, + 0x46, 0xd0, 0x2d, 0xc8, 0x7b, 0x22, 0x52, 0xd6, 0x56, 0xb4, 0xb5, 0xe2, 0xfa, 0xb2, 0x31, 0xd6, + 0x27, 0x43, 0x52, 0x36, 0xe6, 0x5e, 0xfc, 0x59, 0xcb, 0x59, 0x0a, 0xae, 0xdf, 0x82, 0x0b, 0xa2, + 0xde, 0x5d, 0xb1, 0xb9, 0x38, 0xc0, 0x86, 0xc3, 0x7b, 0xd8, 0x53, 0x1b, 0xa2, 0x33, 0x90, 0xdf, + 0x22, 0x4e, 0x77, 0x8b, 0x8b, 0xca, 0xb3, 0x96, 0x5a, 0xe9, 0x1f, 0x41, 0x35, 0x8b, 0xa8, 0x34, + 0xdd, 0x81, 0x7c, 0x53, 0x44, 0x94, 0xa6, 0xff, 0xa7, 0x68, 0x1a, 0x67, 0x2b, 0x8e, 0x5e, 0x87, + 0xff, 0x89, 0xfa, 0xf7, 0x3d, 0xda, 0xda, 0x7a, 0xe8, 0x76, 0x68, 0x24, 0xa8, 0x04, 0xf3, 0x24, + 0x8c, 0x89, 0xaa, 0x73, 0x96, 0x5c, 0xe8, 0x5f, 0xcc, 0xc0, 0x99, 0x83, 0x78, 0xa5, 0x23, 0x95, + 0x80, 0x56, 0x61, 0x81, 0x71, 0xec, 0x73, 0x5b, 0x9d, 0x6e, 0x46, 0x9c, 0xae, 0x28, 0x62, 0x6f, + 0x8b, 0x10, 0xba, 0x00, 0x40, 0xdc, 0x76, 0x04, 0x98, 0x15, 0x80, 0x02, 0x71, 0xdb, 0x2a, 0xdd, + 0x80, 0x92, 0x87, 0x7d, 0xee, 0xb4, 0x1c, 0x4f, 0x1c, 0xc0, 0x56, 0xa7, 0x9d, 0x5b, 0xd1, 0xd6, + 0x16, 0xac, 0xd3, 0x23, 0x39, 0x79, 0x38, 0x74, 0x0d, 0x96, 0x70, 0x8b, 0x3b, 0x7d, 0x62, 0xf7, + 0xf1, 0xb6, 0xd3, 0xc6, 0x9c, 0xfa, 0xac, 0x3c, 0x2f, 0x64, 0x2d, 0xca, 0xc4, 0xe3, 0x38, 0x8e, + 0x6e, 0x43, 0x39, 0x51, 0xc3, 0xed, 0x26, 0x39, 0x79, 0xc1, 0x39, 0x3b, 0x92, 0x1f, 0x52, 0xf5, + 0x9b, 0x50, 0x11, 0xcd, 0x88, 0x43, 0x0f, 0xdd, 0x36, 0xf9, 0x38, 0xea, 0x60, 0x19, 0x8e, 0xe1, + 0x76, 0xdb, 0x27, 0x4c, 0xbe, 0x96, 0x82, 0x15, 0x2d, 0xf5, 0xc7, 0x70, 0x2e, 0x95, 0x17, 0xbf, + 0xb2, 0x79, 0x27, 0x0c, 0xa8, 0x0b, 0x5d, 0x4d, 0xb9, 0xd0, 0x03, 0x4c, 0x89, 0xd7, 0xef, 0x80, + 0x2e, 0xea, 0x6e, 0xd2, 0x0e, 0xbf, 0x47, 0xdd, 0x8e, 0xe3, 0xf7, 0x44, 0x5b, 0x36, 0x39, 0xe6, + 0x01, 0x9b, 0xf6, 0xd4, 0x7e, 0xd6, 0xe0, 0xe2, 0x44, 0xba, 0x92, 0x77, 0x15, 0x96, 0x1c, 0x66, + 0x33, 0xda, 0xe1, 0x76, 0x4b, 0xa2, 0x48, 0x5b, 0x94, 0x3a, 0x6e, 0x9d, 0x72, 0x58, 0x82, 0x4c, + 0xda, 0xa8, 0x06, 0xc5, 0x3e, 0xe5, 0xa4, 0x6d, 0x7b, 0xf4, 0x19, 0xf1, 0xc5, 0xed, 0xcf, 0x59, + 0x20, 0x42, 0x8f, 0xc2, 0x48, 0x08, 0xe0, 0x94, 0xe3, 0x6d, 0x05, 0x98, 0x95, 0x00, 0x11, 0x92, + 0x80, 0xcb, 0x70, 0xea, 0x69, 0x40, 0xfd, 0xa0, 0x67, 0x77, 0xfc, 0xf0, 0xee, 0xa8, 0x2b, 0x6e, + 0xbe, 0x60, 0x9d, 0x94, 0xe1, 0x07, 0x2a, 0xaa, 0xbf, 0x3e, 0x32, 0x29, 0xc4, 0xdf, 0x74, 0xba, + 0x2e, 0xe6, 0x81, 0x4f, 0xd8, 0xf4, 0x19, 0x5b, 0x1a, 0x23, 0x85, 0x6f, 0x28, 0x7e, 0x08, 0xf6, + 0xe8, 0x3d, 0x2e, 0xc6, 0x89, 0xbb, 0x32, 0x8e, 0xce, 0x43, 0x81, 0x45, 0x4c, 0x71, 0xc8, 0x05, + 0x6b, 0x18, 0xd0, 0xbb, 0x50, 0xcb, 0x54, 0xa6, 0x7a, 0xfa, 0x26, 0x40, 0x8c, 0x0f, 0xb7, 0x99, + 0x9d, 0x38, 0xc8, 0x89, 0x12, 0x56, 0x82, 0xa7, 0xaf, 0xa8, 0x16, 0xbc, 0x87, 0x19, 0x57, 0x48, + 0x35, 0x45, 0xd1, 0x77, 0xed, 0xb6, 0x92, 0x92, 0x86, 0x50, 0x52, 0xb2, 0xba, 0xf4, 0x16, 0x94, + 0x47, 0x4e, 0x91, 0xfc, 0x58, 0x1c, 0xa5, 0x59, 0x3a, 0x86, 0xe5, 0x94, 0x42, 0x71, 0x23, 0x4e, + 0x44, 0x1f, 0x68, 0xdb, 0x71, 0x3b, 0x54, 0xcd, 0x40, 0x6d, 0x42, 0x2f, 0x04, 0x7f, 0x01, 0x27, + 0x56, 0xfa, 0x32, 0x9c, 0x1d, 0xed, 0x38, 0x89, 0x3b, 0xf0, 0xaf, 0x06, 0x8b, 0x89, 0xf0, 0x7d, + 0x97, 0xfb, 0x03, 0x74, 0x13, 0x0a, 0x38, 0xe0, 0x5b, 0xd4, 0x77, 0xf8, 0x40, 0xea, 0xde, 0x28, + 0xff, 0xf6, 0x53, 0xbd, 0xa4, 0x6c, 0x49, 0x29, 0xdf, 0xe4, 0xbe, 0xe3, 0x76, 0xad, 0x21, 0x14, + 0xdd, 0x87, 0xa5, 0x56, 0x28, 0xdb, 0x65, 0x01, 0x8b, 0xcf, 0x3d, 0x33, 0x85, 0xbf, 0x18, 0x53, + 0xa2, 0xe7, 0x53, 0x8a, 0x06, 0x3e, 0x7c, 0xfe, 0x27, 0xd4, 0x34, 0xa3, 0x07, 0x90, 0xf7, 0x82, + 0xe6, 0x13, 0x32, 0x10, 0x0f, 0xbe, 0xb8, 0x5e, 0x32, 0xa4, 0x9b, 0x1a, 0x91, 0x9b, 0x1a, 0x77, + 0xdd, 0xc1, 0x46, 0xf9, 0xd7, 0xe1, 0x3e, 0x2d, 0x7f, 0xe0, 0x71, 0x6a, 0x3c, 0x0a, 0x9a, 0xef, + 0x92, 0x81, 0xa5, 0xd8, 0x6f, 0x1c, 0xff, 0xec, 0x79, 0x2d, 0xf7, 0xcf, 0xf3, 0x5a, 0x4e, 0xb7, + 0x0f, 0x5c, 0xa1, 0x68, 0x8b, 0x6a, 0xfc, 0x3d, 0x38, 0x46, 0x5c, 0xee, 0x3b, 0xf1, 0xf3, 0xbb, + 0x38, 0xe9, 0xf9, 0xa9, 0xc6, 0x29, 0x97, 0x8b, 0x98, 0xeb, 0x3f, 0x16, 0x61, 0x5e, 0xec, 0x80, + 0x3e, 0x81, 0xbc, 0x34, 0x42, 0xf4, 0x4a, 0x4a, 0x9d, 0x71, 0xc7, 0xad, 0x5c, 0x9a, 0x06, 0x93, + 0x3a, 0xf5, 0xd5, 0x4f, 0x7f, 0xff, 0xfb, 0xeb, 0x99, 0x73, 0x68, 0xd9, 0x1c, 0xb7, 0x76, 0x69, + 0xb6, 0xe8, 0x3b, 0x2d, 0x1a, 0xe8, 0xa4, 0x29, 0x5c, 0xcf, 0xda, 0x20, 0xcb, 0x93, 0x2b, 0x8d, + 0x23, 0x30, 0x94, 0x3a, 0x53, 0xa8, 0xbb, 0x82, 0x2e, 0x9b, 0x59, 0x7f, 0x78, 0x08, 0x96, 0xb9, + 0x23, 0x87, 0x6a, 0x17, 0x7d, 0xae, 0x41, 0x21, 0xf6, 0x52, 0xb4, 0x96, 0xb5, 0xe3, 0x41, 0x7b, + 0xae, 0x5c, 0x39, 0x04, 0x52, 0x69, 0x5a, 0x13, 0x9a, 0x74, 0xb4, 0x92, 0xa2, 0x49, 0x98, 0xb4, + 0xb9, 0x23, 0xfe, 0xd9, 0x45, 0xdf, 0x68, 0x70, 0x72, 0xd4, 0x59, 0x50, 0x3d, 0x6b, 0x9f, 0x54, + 0xcf, 0xab, 0x18, 0x87, 0x85, 0x2b, 0x6d, 0x86, 0xd0, 0xb6, 0x86, 0x2e, 0xa5, 0x68, 0x8b, 0x3f, + 0x1c, 0xe6, 0x8e, 0x1a, 0xad, 0x5d, 0xf4, 0x8b, 0x06, 0x67, 0xd2, 0xed, 0x09, 0xdd, 0xc8, 0xda, + 0x7a, 0xa2, 0x1b, 0x56, 0x6e, 0x1e, 0x95, 0xa6, 0x94, 0xdf, 0x10, 0xca, 0x4d, 0x54, 0x4f, 0x51, + 0x1e, 0x7a, 0x63, 0xbd, 0x95, 0xe0, 0x0e, 0xef, 0xfb, 0x7b, 0x0d, 0xd0, 0xb8, 0x0f, 0xa0, 0x29, + 0x4f, 0x2d, 0xc5, 0xcd, 0x2a, 0xeb, 0x47, 0xa1, 0x1c, 0xa2, 0xdd, 0x43, 0x1f, 0x19, 0xaa, 0xfd, + 0x41, 0x03, 0x34, 0x6e, 0x15, 0xd9, 0x6a, 0x33, 0x8d, 0x27, 0x5b, 0x6d, 0xb6, 0x13, 0x4d, 0x1c, + 0xa6, 0x6d, 0xcc, 0x78, 0x5d, 0x7d, 0xf3, 0xdb, 0x75, 0xa9, 0x17, 0x7d, 0xab, 0xc1, 0x42, 0xd2, + 0x15, 0xd0, 0xb5, 0x69, 0x3d, 0x4a, 0x8e, 0xd4, 0xab, 0x87, 0x03, 0x2b, 0x71, 0xb7, 0x84, 0xb8, + 0x06, 0x32, 0xcd, 0xec, 0x9f, 0x18, 0xe6, 0xce, 0x98, 0x2b, 0xee, 0xa2, 0x2f, 0x35, 0x28, 0x26, + 0xbe, 0xa3, 0xe8, 0xea, 0xd4, 0x7b, 0x8c, 0xcd, 0xab, 0x72, 0xed, 0x50, 0x58, 0xa5, 0xf0, 0xb2, + 0x50, 0xb8, 0x8a, 0x6a, 0x13, 0x14, 0xda, 0x8c, 0xf0, 0x8d, 0x77, 0x5e, 0xec, 0x55, 0xb5, 0x97, + 0x7b, 0x55, 0xed, 0xaf, 0xbd, 0xaa, 0xf6, 0xd5, 0x7e, 0x35, 0xf7, 0x72, 0xbf, 0x9a, 0xfb, 0x63, + 0xbf, 0x9a, 0xfb, 0xf0, 0x7a, 0xd7, 0xe1, 0x5b, 0x41, 0xd3, 0x68, 0xd1, 0x9e, 0x49, 0xfa, 0x8c, + 0xe3, 0xd6, 0x13, 0x93, 0xf4, 0xeb, 0xa2, 0x5a, 0x8f, 0xb6, 0x83, 0x6d, 0xc2, 0xe2, 0xaa, 0xe2, + 0x67, 0x57, 0x33, 0x2f, 0xcc, 0xe9, 0xb5, 0xff, 0x02, 0x00, 0x00, 0xff, 0xff, 0x10, 0xfd, 0x50, + 0xd7, 0x60, 0x0e, 0x00, 0x00, } // Reference imports to suppress errors if they are not otherwise used. @@ -971,6 +1113,8 @@ type QueryClient interface { LastAttestedHeight(ctx context.Context, in *QueryLastAttestedHeightRequest, opts ...grpc.CallOption) (*QueryLastAttestedHeightResponse, error) // AttesterInfo queries the attester information including public key AttesterInfo(ctx context.Context, in *QueryAttesterInfoRequest, opts ...grpc.CallOption) (*QueryAttesterInfoResponse, error) + // AttesterSet queries the full ordered attester set + AttesterSet(ctx context.Context, in *QueryAttesterSetRequest, opts ...grpc.CallOption) (*QueryAttesterSetResponse, error) } type queryClient struct { @@ -1053,6 +1197,15 @@ func (c *queryClient) AttesterInfo(ctx context.Context, in *QueryAttesterInfoReq return out, nil } +func (c *queryClient) AttesterSet(ctx context.Context, in *QueryAttesterSetRequest, opts ...grpc.CallOption) (*QueryAttesterSetResponse, error) { + out := new(QueryAttesterSetResponse) + err := c.cc.Invoke(ctx, "/evabci.network.v1.Query/AttesterSet", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // QueryServer is the server API for Query service. type QueryServer interface { // Params queries the module parameters @@ -1071,6 +1224,8 @@ type QueryServer interface { LastAttestedHeight(context.Context, *QueryLastAttestedHeightRequest) (*QueryLastAttestedHeightResponse, error) // AttesterInfo queries the attester information including public key AttesterInfo(context.Context, *QueryAttesterInfoRequest) (*QueryAttesterInfoResponse, error) + // AttesterSet queries the full ordered attester set + AttesterSet(context.Context, *QueryAttesterSetRequest) (*QueryAttesterSetResponse, error) } // UnimplementedQueryServer can be embedded to have forward compatible implementations. @@ -1101,6 +1256,9 @@ func (*UnimplementedQueryServer) LastAttestedHeight(ctx context.Context, req *Qu func (*UnimplementedQueryServer) AttesterInfo(ctx context.Context, req *QueryAttesterInfoRequest) (*QueryAttesterInfoResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method AttesterInfo not implemented") } +func (*UnimplementedQueryServer) AttesterSet(ctx context.Context, req *QueryAttesterSetRequest) (*QueryAttesterSetResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AttesterSet not implemented") +} func RegisterQueryServer(s grpc1.Server, srv QueryServer) { s.RegisterService(&_Query_serviceDesc, srv) @@ -1250,6 +1408,24 @@ func _Query_AttesterInfo_Handler(srv interface{}, ctx context.Context, dec func( return interceptor(ctx, in, info, handler) } +func _Query_AttesterSet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryAttesterSetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(QueryServer).AttesterSet(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/evabci.network.v1.Query/AttesterSet", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(QueryServer).AttesterSet(ctx, req.(*QueryAttesterSetRequest)) + } + return interceptor(ctx, in, info, handler) +} + var Query_serviceDesc = _Query_serviceDesc var _Query_serviceDesc = grpc.ServiceDesc{ ServiceName: "evabci.network.v1.Query", @@ -1287,6 +1463,10 @@ var _Query_serviceDesc = grpc.ServiceDesc{ MethodName: "AttesterInfo", Handler: _Query_AttesterInfo_Handler, }, + { + MethodName: "AttesterSet", + Handler: _Query_AttesterSet_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "evabci/network/v1/query.proto", @@ -1855,6 +2035,120 @@ func (m *QueryAttesterInfoResponse) MarshalToSizedBuffer(dAtA []byte) (int, erro return len(dAtA) - i, nil } +func (m *QueryAttesterSetRequest) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *QueryAttesterSetRequest) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *QueryAttesterSetRequest) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + return len(dAtA) - i, nil +} + +func (m *AttesterSetEntry) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AttesterSetEntry) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *AttesterSetEntry) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if m.Pubkey != nil { + { + size, err := m.Pubkey.MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintQuery(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0x22 + } + if m.Index != 0 { + i = encodeVarintQuery(dAtA, i, uint64(m.Index)) + i-- + dAtA[i] = 0x18 + } + if len(m.ConsensusAddress) > 0 { + i -= len(m.ConsensusAddress) + copy(dAtA[i:], m.ConsensusAddress) + i = encodeVarintQuery(dAtA, i, uint64(len(m.ConsensusAddress))) + i-- + dAtA[i] = 0x12 + } + if len(m.Authority) > 0 { + i -= len(m.Authority) + copy(dAtA[i:], m.Authority) + i = encodeVarintQuery(dAtA, i, uint64(len(m.Authority))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *QueryAttesterSetResponse) Marshal() (dAtA []byte, err error) { + size := m.Size() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBuffer(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *QueryAttesterSetResponse) MarshalTo(dAtA []byte) (int, error) { + size := m.Size() + return m.MarshalToSizedBuffer(dAtA[:size]) +} + +func (m *QueryAttesterSetResponse) MarshalToSizedBuffer(dAtA []byte) (int, error) { + i := len(dAtA) + _ = i + var l int + _ = l + if len(m.Entries) > 0 { + for iNdEx := len(m.Entries) - 1; iNdEx >= 0; iNdEx-- { + { + size, err := m.Entries[iNdEx].MarshalToSizedBuffer(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarintQuery(dAtA, i, uint64(size)) + } + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + func encodeVarintQuery(dAtA []byte, offset int, v uint64) int { offset -= sovQuery(v) base := offset @@ -2102,6 +2396,54 @@ func (m *QueryAttesterInfoResponse) Size() (n int) { return n } +func (m *QueryAttesterSetRequest) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + return n +} + +func (m *AttesterSetEntry) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Authority) + if l > 0 { + n += 1 + l + sovQuery(uint64(l)) + } + l = len(m.ConsensusAddress) + if l > 0 { + n += 1 + l + sovQuery(uint64(l)) + } + if m.Index != 0 { + n += 1 + sovQuery(uint64(m.Index)) + } + if m.Pubkey != nil { + l = m.Pubkey.Size() + n += 1 + l + sovQuery(uint64(l)) + } + return n +} + +func (m *QueryAttesterSetResponse) Size() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Entries) > 0 { + for _, e := range m.Entries { + l = e.Size() + n += 1 + l + sovQuery(uint64(l)) + } + } + return n +} + func sovQuery(x uint64) (n int) { return (math_bits.Len64(x|1) + 6) / 7 } @@ -3577,6 +3919,309 @@ func (m *QueryAttesterInfoResponse) Unmarshal(dAtA []byte) error { } return nil } +func (m *QueryAttesterSetRequest) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQuery + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: QueryAttesterSetRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: QueryAttesterSetRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skipQuery(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthQuery + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AttesterSetEntry) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQuery + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AttesterSetEntry: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AttesterSetEntry: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Authority", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQuery + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthQuery + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthQuery + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Authority = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConsensusAddress", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQuery + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLengthQuery + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLengthQuery + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConsensusAddress = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Index", wireType) + } + m.Index = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQuery + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Index |= uint32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Pubkey", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQuery + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthQuery + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthQuery + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Pubkey == nil { + m.Pubkey = &types.Any{} + } + if err := m.Pubkey.Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipQuery(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthQuery + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *QueryAttesterSetResponse) Unmarshal(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQuery + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: QueryAttesterSetResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: QueryAttesterSetResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Entries", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflowQuery + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLengthQuery + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLengthQuery + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Entries = append(m.Entries, AttesterSetEntry{}) + if err := m.Entries[len(m.Entries)-1].Unmarshal(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skipQuery(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLengthQuery + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func skipQuery(dAtA []byte) (n int, err error) { l := len(dAtA) iNdEx := 0 diff --git a/modules/network/types/query.pb.gw.go b/modules/network/types/query.pb.gw.go index 1dc93cde..91b4d014 100644 --- a/modules/network/types/query.pb.gw.go +++ b/modules/network/types/query.pb.gw.go @@ -393,6 +393,24 @@ func local_request_Query_AttesterInfo_0(ctx context.Context, marshaler runtime.M } +func request_Query_AttesterSet_0(ctx context.Context, marshaler runtime.Marshaler, client QueryClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq QueryAttesterSetRequest + var metadata runtime.ServerMetadata + + msg, err := client.AttesterSet(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_Query_AttesterSet_0(ctx context.Context, marshaler runtime.Marshaler, server QueryServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq QueryAttesterSetRequest + var metadata runtime.ServerMetadata + + msg, err := server.AttesterSet(ctx, &protoReq) + return msg, metadata, err + +} + // RegisterQueryHandlerServer registers the http handlers for service Query to "mux". // UnaryRPC :call QueryServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -583,6 +601,29 @@ func RegisterQueryHandlerServer(ctx context.Context, mux *runtime.ServeMux, serv }) + mux.Handle("GET", pattern_Query_AttesterSet_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + rctx, err := runtime.AnnotateIncomingContext(ctx, mux, req) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_Query_AttesterSet_0(rctx, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + ctx = runtime.NewServerMetadataContext(ctx, md) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + + forward_Query_AttesterSet_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + return nil } @@ -784,6 +825,26 @@ func RegisterQueryHandlerClient(ctx context.Context, mux *runtime.ServeMux, clie }) + mux.Handle("GET", pattern_Query_AttesterSet_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + rctx, err := runtime.AnnotateContext(ctx, mux, req) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_Query_AttesterSet_0(rctx, inboundMarshaler, client, req, pathParams) + ctx = runtime.NewServerMetadataContext(ctx, md) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + + forward_Query_AttesterSet_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + return nil } @@ -803,6 +864,8 @@ var ( pattern_Query_LastAttestedHeight_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"evabci", "network", "v1", "last-attested-height"}, "", runtime.AssumeColonVerbOpt(false))) pattern_Query_AttesterInfo_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"evabci", "network", "v1", "attester", "validator_address"}, "", runtime.AssumeColonVerbOpt(false))) + + pattern_Query_AttesterSet_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"evabci", "network", "v1", "attester_set"}, "", runtime.AssumeColonVerbOpt(false))) ) var ( @@ -821,4 +884,6 @@ var ( forward_Query_LastAttestedHeight_0 = runtime.ForwardResponseMessage forward_Query_AttesterInfo_0 = runtime.ForwardResponseMessage + + forward_Query_AttesterSet_0 = runtime.ForwardResponseMessage ) diff --git a/modules/proto/evabci/network/v1/query.proto b/modules/proto/evabci/network/v1/query.proto index 366c39bb..ac16858b 100644 --- a/modules/proto/evabci/network/v1/query.proto +++ b/modules/proto/evabci/network/v1/query.proto @@ -6,6 +6,8 @@ option go_package = "github.com/evstack/ev-abci/modules/network/types"; import "gogoproto/gogo.proto"; import "google/api/annotations.proto"; +import "google/protobuf/any.proto"; +import "cosmos_proto/cosmos.proto"; import "cosmos/base/query/v1beta1/pagination.proto"; import "evabci/network/v1/types.proto"; import "evabci/network/v1/attester.proto"; @@ -51,6 +53,11 @@ service Query { rpc AttesterInfo(QueryAttesterInfoRequest) returns (QueryAttesterInfoResponse) { option (google.api.http).get = "/evabci/network/v1/attester/{validator_address}"; } + + // AttesterSet queries the full ordered attester set + rpc AttesterSet(QueryAttesterSetRequest) returns (QueryAttesterSetResponse) { + option (google.api.http).get = "/evabci/network/v1/attester_set"; + } } // QueryParamsRequest is the request type for the Query/Params RPC method. @@ -143,3 +150,21 @@ message QueryAttesterInfoRequest { message QueryAttesterInfoResponse { AttesterInfo attester_info = 1; } + +// QueryAttesterSetRequest is the request type for the Query/AttesterSet RPC method. +message QueryAttesterSetRequest {} + +// AttesterSetEntry is a single entry in the attester set, ordered by index. +message AttesterSetEntry { + option (gogoproto.equal) = false; + option (gogoproto.goproto_getters) = false; + string authority = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + string consensus_address = 2 [(cosmos_proto.scalar) = "cosmos.AddressString"]; + uint32 index = 3; + google.protobuf.Any pubkey = 4 [(cosmos_proto.accepts_interface) = "cosmos.crypto.PubKey"]; +} + +// QueryAttesterSetResponse is the response type for the Query/AttesterSet RPC method. +message QueryAttesterSetResponse { + repeated AttesterSetEntry entries = 1 [(gogoproto.nullable) = false]; +} diff --git a/pkg/adapter/providers_test.go b/pkg/adapter/providers_test.go new file mode 100644 index 00000000..d9cb9a91 --- /dev/null +++ b/pkg/adapter/providers_test.go @@ -0,0 +1,66 @@ +package adapter_test + +import ( + "bytes" + "context" + "sort" + "testing" + + tmcryptoed25519 "github.com/cometbft/cometbft/crypto/ed25519" + cmtstate "github.com/cometbft/cometbft/state" + cmttypes "github.com/cometbft/cometbft/types" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/stretchr/testify/require" + + "github.com/evstack/ev-abci/pkg/adapter" +) + +type mockStateStore struct { + state *cmtstate.State +} + +func (m *mockStateStore) LoadState(_ context.Context) (*cmtstate.State, error) { + return m.state, nil +} + +func TestValidatorHasherOrderingMatchesAddressSort(t *testing.T) { + // 3 random ed25519 validators + keys := []tmcryptoed25519.PubKey{ + tmcryptoed25519.GenPrivKey().PubKey().(tmcryptoed25519.PubKey), + tmcryptoed25519.GenPrivKey().PubKey().(tmcryptoed25519.PubKey), + tmcryptoed25519.GenPrivKey().PubKey().(tmcryptoed25519.PubKey), + } + + // Canonical: sort by Address() bytes ascending, convert to libp2p, hash. + canonicalOrder := make([]tmcryptoed25519.PubKey, len(keys)) + copy(canonicalOrder, keys) + sort.Slice(canonicalOrder, func(i, j int) bool { + return bytes.Compare(canonicalOrder[i].Address(), canonicalOrder[j].Address()) < 0 + }) + libp2pCanonical := make([]crypto.PubKey, len(canonicalOrder)) + for i, k := range canonicalOrder { + p, err := crypto.UnmarshalEd25519PublicKey(k.Bytes()) + require.NoError(t, err) + libp2pCanonical[i] = p + } + sequencerAddr := canonicalOrder[0].Address().Bytes() + canonicalHash, err := adapter.ValidatorsHasher(libp2pCanonical, sequencerAddr) + require.NoError(t, err) + + // Build a cmttypes.ValidatorSet via NewValidatorSet (will sort internally). + vals := make([]*cmttypes.Validator, len(keys)) + for i, k := range keys { + vals[i] = cmttypes.NewValidator(k, 1) + } + vs := cmttypes.NewValidatorSet(vals) + + // Wrap in a mock state and call the provider. + st := &cmtstate.State{Validators: vs} + store := &mockStateStore{state: st} + hasher := adapter.ValidatorHasherFromStoreProvider(store) + gotHash, err := hasher(sequencerAddr, nil) + require.NoError(t, err) + + require.Equal(t, []byte(canonicalHash), []byte(gotHash), + "provider hash must match address-sorted canonical hash") +} diff --git a/pkg/rpc/core/blocks.go b/pkg/rpc/core/blocks.go index 73921e5d..d3c04bbb 100644 --- a/pkg/rpc/core/blocks.go +++ b/pkg/rpc/core/blocks.go @@ -15,7 +15,7 @@ import ( ctypes "github.com/cometbft/cometbft/rpc/core/types" rpctypes "github.com/cometbft/cometbft/rpc/jsonrpc/types" cmttypes "github.com/cometbft/cometbft/types" - sdk "github.com/cosmos/cosmos-sdk/types" + cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" "github.com/cosmos/gogoproto/proto" storepkg "github.com/evstack/ev-node/pkg/store" @@ -334,82 +334,109 @@ func BlockchainInfo(ctx *rpctypes.Context, minHeight, maxHeight int64) (*ctypes. }, nil } -// getCommitForHeight returns commit info for a specific height, -// using attester signatures if in attester mode, otherwise sequencer signatures +// getCommitForHeight returns a deterministic cmttypes.Commit for height. +// In attester mode it builds the commit from the ordered attester set, placing +// BlockIDFlagAbsent for non-signers and refusing to return until 2/3 quorum is met. func getCommitForHeight(ctx context.Context, height uint64) (*cmttypes.Commit, error) { - // Debug: Log attester mode status - env.Logger.Info("getCommitForHeight called", - "height", height, - "AttesterMode", env.AttesterMode) - - // If not in attester mode, use the original sequencer-based commit if !env.AttesterMode { - env.Logger.Info("Using sequencer mode - returning sequencer signatures") return env.Adapter.GetLastCommit(ctx, height+1) } - // In attester mode, try to construct commit from attester signatures blockID, err := env.Adapter.Store.GetBlockID(ctx, height) if err != nil { return nil, fmt.Errorf("get block ID for height %d: %w", height, err) } - // Query attester signatures from the network module - env.Logger.Info("In attester mode - querying attester signatures", "height", height) + entries, err := getAttesterSet(ctx) + if err != nil { + return nil, fmt.Errorf("get attester set: %w", err) + } signatures, err := getAttesterSignatures(ctx, int64(height)) if err != nil { - env.Logger.Error("failed to get attester signatures", - "height", height, "error", err) - return nil, fmt.Errorf("attester mode: failed to get attester signatures for height %d: %w", height, err) + return nil, fmt.Errorf("get attester signatures: %w", err) } - // Build commit with attester signatures - commitSigs := make([]cmttypes.CommitSig, 0, len(signatures)) - for validatorAddr, signature := range signatures { - // Parse the signature bytes (they should be marshaled cmtproto.Vote) - var vote cmtproto.Vote - if err := proto.Unmarshal(signature, &vote); err != nil { - env.Logger.Error("failed to unmarshal attester vote", - "validator", validatorAddr, "error", err) + commitSigs := make([]cmttypes.CommitSig, 0, len(entries)) + signedCount := 0 + for _, e := range entries { + voteBytes, ok := signatures[e.ConsensusAddress] + if !ok { + commitSigs = append(commitSigs, cmttypes.CommitSig{BlockIDFlag: cmttypes.BlockIDFlagAbsent}) continue } - - // Decode bech32 validator address to get 20-byte address - valAddrBytes, err := sdk.ValAddressFromBech32(validatorAddr) - if err != nil { - env.Logger.Error("failed to decode validator address", - "validator", validatorAddr, "error", err) + var vote cmtproto.Vote + if err := proto.Unmarshal(voteBytes, &vote); err != nil { + commitSigs = append(commitSigs, cmttypes.CommitSig{BlockIDFlag: cmttypes.BlockIDFlagAbsent}) continue } - commitSigs = append(commitSigs, cmttypes.CommitSig{ BlockIDFlag: cmttypes.BlockIDFlagCommit, - ValidatorAddress: cmttypes.Address(valAddrBytes), + ValidatorAddress: e.ValidatorAddress, Timestamp: vote.Timestamp, Signature: vote.Signature, }) + signedCount++ } - // If no valid attester signatures, return error instead of fallback - if len(commitSigs) == 0 { - env.Logger.Error("no attester signatures found for block", "height", height) - return nil, fmt.Errorf("attester mode: no attester signatures found for height %d - block not attested", height) + total := len(entries) + if signedCount*3 <= total*2 { + return nil, fmt.Errorf("height %d not yet attested (signed %d of %d)", height, signedCount, total) } return &cmttypes.Commit{ - Height: int64(height), + Height: int64(height), //nolint:gosec Round: 0, BlockID: *blockID, Signatures: commitSigs, }, nil } -// getAttesterSignatures queries the network module to get all attester signatures for a height -func getAttesterSignatures(ctx context.Context, height int64) (map[string][]byte, error) { - // Use the new AttesterSignatures gRPC endpoint - env.Logger.Info("Querying AttesterSignatures endpoint", "height", height) +// attesterSetEntry holds an ordered attester set entry used for commit reconstruction. +type attesterSetEntry struct { + ConsensusAddress string + ValidatorAddress []byte + Pubkey cryptotypes.PubKey +} + +// getAttesterSet fetches the ordered attester set from the network module via ABCI query. +func getAttesterSet(ctx context.Context) ([]attesterSetEntry, error) { + req, err := proto.Marshal(&networktypes.QueryAttesterSetRequest{}) + if err != nil { + return nil, err + } + result, err := env.Adapter.App.Query(ctx, &abci.RequestQuery{ + Path: "/evabci.network.v1.Query/AttesterSet", + Data: req, + }) + if err != nil { + return nil, err + } + if result.Code != 0 { + return nil, fmt.Errorf("query AttesterSet failed: %s", result.Log) + } + var resp networktypes.QueryAttesterSetResponse + if err := proto.Unmarshal(result.Value, &resp); err != nil { + return nil, err + } + sort.Slice(resp.Entries, func(i, j int) bool { return resp.Entries[i].Index < resp.Entries[j].Index }) + + out := make([]attesterSetEntry, 0, len(resp.Entries)) + for _, e := range resp.Entries { + var pk cryptotypes.PubKey + if err := networktypes.ModuleCdc.InterfaceRegistry().UnpackAny(e.Pubkey, &pk); err != nil { + return nil, fmt.Errorf("unpack pubkey for %s: %w", e.ConsensusAddress, err) + } + out = append(out, attesterSetEntry{ + ConsensusAddress: e.ConsensusAddress, + ValidatorAddress: pk.Address(), + Pubkey: pk, + }) + } + return out, nil +} - // Query individual attester signatures using the new endpoint +// getAttesterSignatures queries the network module to get all attester signatures for a height. +func getAttesterSignatures(ctx context.Context, height int64) (map[string][]byte, error) { signaturesReq, err := proto.Marshal(&networktypes.QueryAttesterSignaturesRequest{Height: height}) if err != nil { return nil, fmt.Errorf("marshal attester signatures request: %w", err) @@ -420,12 +447,10 @@ func getAttesterSignatures(ctx context.Context, height int64) (map[string][]byte Data: signaturesReq, }) if err != nil { - env.Logger.Debug("AttesterSignatures query failed", "error", err) return make(map[string][]byte), nil } if result.Code != 0 { - env.Logger.Info("AttesterSignatures not found", "height", height, "code", result.Code) return make(map[string][]byte), nil } @@ -434,13 +459,18 @@ func getAttesterSignatures(ctx context.Context, height int64) (map[string][]byte return nil, fmt.Errorf("unmarshal attester signatures response: %w", err) } - // Convert to map format signatures := make(map[string][]byte) for _, sig := range signaturesResp.Signatures { signatures[sig.ValidatorAddress] = sig.Signature } - env.Logger.Info("Found AttesterSignatures", "height", height, "count", len(signatures)) - return signatures, nil } + +// GetCommitForHeightForTest is exported only for tests in this package. +func GetCommitForHeightForTest(ctx context.Context, e *Environment, height uint64) (*cmttypes.Commit, error) { + previousEnv := env + env = e + defer func() { env = previousEnv }() + return getCommitForHeight(ctx, height) +} diff --git a/pkg/rpc/core/commit_reconstruction_test.go b/pkg/rpc/core/commit_reconstruction_test.go new file mode 100644 index 00000000..1fb341ae --- /dev/null +++ b/pkg/rpc/core/commit_reconstruction_test.go @@ -0,0 +1,295 @@ +package core_test + +import ( + "bytes" + "context" + "sort" + "testing" + "time" + + abci "github.com/cometbft/cometbft/abci/types" + cmted25519 "github.com/cometbft/cometbft/crypto/ed25519" + cmtlog "github.com/cometbft/cometbft/libs/log" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cmttypes "github.com/cometbft/cometbft/types" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/gogoproto/proto" + ds "github.com/ipfs/go-datastore" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + networktypes "github.com/evstack/ev-abci/modules/network/types" + "github.com/evstack/ev-abci/pkg/adapter" + "github.com/evstack/ev-abci/pkg/rpc/core" + execstore "github.com/evstack/ev-abci/pkg/store" +) + +// mockABCI is a minimal mock for the servertypes.ABCI interface. +type mockABCI struct { + mock.Mock +} + +func (m *mockABCI) Info(req *abci.RequestInfo) (*abci.ResponseInfo, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseInfo) + return r, args.Error(1) +} + +func (m *mockABCI) Query(ctx context.Context, req *abci.RequestQuery) (*abci.ResponseQuery, error) { + args := m.Called(ctx, req) + r, _ := args.Get(0).(*abci.ResponseQuery) + return r, args.Error(1) +} + +func (m *mockABCI) CheckTx(req *abci.RequestCheckTx) (*abci.ResponseCheckTx, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseCheckTx) + return r, args.Error(1) +} + +func (m *mockABCI) InitChain(req *abci.RequestInitChain) (*abci.ResponseInitChain, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseInitChain) + return r, args.Error(1) +} + +func (m *mockABCI) PrepareProposal(req *abci.RequestPrepareProposal) (*abci.ResponsePrepareProposal, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponsePrepareProposal) + return r, args.Error(1) +} + +func (m *mockABCI) ProcessProposal(req *abci.RequestProcessProposal) (*abci.ResponseProcessProposal, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseProcessProposal) + return r, args.Error(1) +} + +func (m *mockABCI) FinalizeBlock(req *abci.RequestFinalizeBlock) (*abci.ResponseFinalizeBlock, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseFinalizeBlock) + return r, args.Error(1) +} + +func (m *mockABCI) ExtendVote(ctx context.Context, req *abci.RequestExtendVote) (*abci.ResponseExtendVote, error) { + args := m.Called(ctx, req) + r, _ := args.Get(0).(*abci.ResponseExtendVote) + return r, args.Error(1) +} + +func (m *mockABCI) VerifyVoteExtension(req *abci.RequestVerifyVoteExtension) (*abci.ResponseVerifyVoteExtension, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseVerifyVoteExtension) + return r, args.Error(1) +} + +func (m *mockABCI) Commit() (*abci.ResponseCommit, error) { + args := m.Called() + r, _ := args.Get(0).(*abci.ResponseCommit) + return r, args.Error(1) +} + +func (m *mockABCI) ListSnapshots(req *abci.RequestListSnapshots) (*abci.ResponseListSnapshots, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseListSnapshots) + return r, args.Error(1) +} + +func (m *mockABCI) OfferSnapshot(req *abci.RequestOfferSnapshot) (*abci.ResponseOfferSnapshot, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseOfferSnapshot) + return r, args.Error(1) +} + +func (m *mockABCI) LoadSnapshotChunk(req *abci.RequestLoadSnapshotChunk) (*abci.ResponseLoadSnapshotChunk, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseLoadSnapshotChunk) + return r, args.Error(1) +} + +func (m *mockABCI) ApplySnapshotChunk(req *abci.RequestApplySnapshotChunk) (*abci.ResponseApplySnapshotChunk, error) { + args := m.Called(req) + r, _ := args.Get(0).(*abci.ResponseApplySnapshotChunk) + return r, args.Error(1) +} + +// buildEnv sets up a test Environment and returns it along with the canonical +// ValidatorSet and BlockID used for verification. +func buildEnv(t *testing.T, height uint64, keys []cmted25519.PrivKey, signers []int, chainID string) (*core.Environment, *cmttypes.ValidatorSet, cmttypes.BlockID) { + t.Helper() + + blockIDHash := bytes.Repeat([]byte{0xab}, 32) + + // Build canonical ValidatorSet (NewValidatorSet sorts by address internally). + vals := make([]*cmttypes.Validator, len(keys)) + for i, k := range keys { + vals[i] = cmttypes.NewValidator(k.PubKey(), 1) + } + valSet := cmttypes.NewValidatorSet(vals) + + // Map raw 20-byte address → private key for signing. + privByAddr := map[string]cmted25519.PrivKey{} + for _, priv := range keys { + privByAddr[string(priv.PubKey().Address())] = priv + } + + // Build ordered list following valSet order (already sorted by address). + consAddrs := make([]string, len(valSet.Validators)) + pubkeyAnys := make([]*codectypes.Any, len(valSet.Validators)) + for i, v := range valSet.Validators { + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(v.PubKey) + require.NoError(t, err) + consAddrs[i] = sdk.ConsAddress(v.Address).String() + any, err := codectypes.NewAnyWithValue(sdkPk) + require.NoError(t, err) + pubkeyAnys[i] = any + } + + // Sign for each selected signer index (indices into valSet.Validators). + signatures := map[string][]byte{} + for _, i := range signers { + priv := privByAddr[string(valSet.Validators[i].Address)] + v := cmtproto.Vote{ + Type: cmtproto.PrecommitType, + Height: int64(height), //nolint:gosec + Round: 0, + BlockID: cmtproto.BlockID{Hash: blockIDHash, PartSetHeader: cmtproto.PartSetHeader{}}, + Timestamp: time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC), + ValidatorAddress: valSet.Validators[i].Address, + ValidatorIndex: int32(i), //nolint:gosec + } + sb := cmttypes.VoteSignBytes(chainID, &v) + sig, err := priv.Sign(sb) + require.NoError(t, err) + v.Signature = sig + bz, err := proto.Marshal(&v) + require.NoError(t, err) + signatures[consAddrs[i]] = bz + } + + // Prepare AttesterSet query response (index == position in sorted valSet). + setEntries := make([]networktypes.AttesterSetEntry, 0, len(valSet.Validators)) + for i := range valSet.Validators { + setEntries = append(setEntries, networktypes.AttesterSetEntry{ + Authority: sdk.AccAddress(valSet.Validators[i].Address).String(), + ConsensusAddress: consAddrs[i], + Index: uint32(i), //nolint:gosec + Pubkey: pubkeyAnys[i], + }) + } + setRespBz, err := proto.Marshal(&networktypes.QueryAttesterSetResponse{Entries: setEntries}) + require.NoError(t, err) + + // Prepare AttesterSignatures query response. + sigList := make([]*networktypes.AttesterSignature, 0, len(signatures)) + for consAddr, sig := range signatures { + sigList = append(sigList, &networktypes.AttesterSignature{ + ValidatorAddress: consAddr, + Signature: sig, + }) + } + sigRespBz, err := proto.Marshal(&networktypes.QueryAttesterSignaturesResponse{Signatures: sigList}) + require.NoError(t, err) + + mApp := new(mockABCI) + mApp.On("Query", mock.Anything, mock.MatchedBy(func(r *abci.RequestQuery) bool { + return r.Path == "/evabci.network.v1.Query/AttesterSet" + })).Return(&abci.ResponseQuery{Code: 0, Value: setRespBz}, nil) + mApp.On("Query", mock.Anything, mock.MatchedBy(func(r *abci.RequestQuery) bool { + return r.Path == "/evabci.network.v1.Query/AttesterSignatures" + })).Return(&abci.ResponseQuery{Code: 0, Value: sigRespBz}, nil) + + // Use real store with in-memory backend, save the block ID. + dsStore := ds.NewMapDatastore() + abciExecStore := execstore.NewExecABCIStore(dsStore) + blockID := cmttypes.BlockID{Hash: blockIDHash, PartSetHeader: cmttypes.PartSetHeader{}} + err = abciExecStore.SaveBlockID(context.Background(), height, &blockID) + require.NoError(t, err) + + env := &core.Environment{ + Adapter: &adapter.Adapter{ + App: mApp, + Store: abciExecStore, + }, + AttesterMode: true, + Logger: cmtlog.NewNopLogger(), + } + + return env, valSet, blockID +} + +func TestGetCommitForHeight_QuorumMet_SortedWithAbsent(t *testing.T) { + chainID := "test-chain" + keys := []cmted25519.PrivKey{ + cmted25519.GenPrivKey(), cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), cmted25519.GenPrivKey(), + } + signers := []int{0, 1, 2} // 3 of 4 — quorum + env, valSet, blockID := buildEnv(t, 100, keys, signers, chainID) + + commit, err := core.GetCommitForHeightForTest(context.Background(), env, 100) + require.NoError(t, err) + require.Equal(t, int64(100), commit.Height) + require.Equal(t, int32(0), commit.Round) + require.Equal(t, blockID, commit.BlockID) + require.Len(t, commit.Signatures, 4) + + // Committed validator addresses must be in ascending order. + var addrs [][]byte + for _, cs := range commit.Signatures { + if cs.BlockIDFlag == cmttypes.BlockIDFlagCommit { + addrs = append(addrs, cs.ValidatorAddress) + } + } + require.True(t, sort.SliceIsSorted(addrs, func(i, j int) bool { + return bytes.Compare(addrs[i], addrs[j]) < 0 + })) + + var commitCnt, absentCnt int + for _, cs := range commit.Signatures { + switch cs.BlockIDFlag { + case cmttypes.BlockIDFlagCommit: + commitCnt++ + case cmttypes.BlockIDFlagAbsent: + absentCnt++ + } + } + require.Equal(t, 3, commitCnt) + require.Equal(t, 1, absentCnt) + + // 07-tendermint light client must accept this commit. + require.NoError(t, valSet.VerifyCommitLight(chainID, blockID, 100, commit)) +} + +func TestGetCommitForHeight_NoQuorum_Error(t *testing.T) { + chainID := "test-chain" + keys := []cmted25519.PrivKey{ + cmted25519.GenPrivKey(), cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), cmted25519.GenPrivKey(), + } + signers := []int{0, 1} // 2 of 4 — not > 2/3 + env, _, _ := buildEnv(t, 200, keys, signers, chainID) + + _, err := core.GetCommitForHeightForTest(context.Background(), env, 200) + require.Error(t, err) + require.Contains(t, err.Error(), "not yet attested") +} + +func TestGetCommitForHeight_AllSigned(t *testing.T) { + chainID := "test-chain" + keys := []cmted25519.PrivKey{ + cmted25519.GenPrivKey(), cmted25519.GenPrivKey(), + cmted25519.GenPrivKey(), cmted25519.GenPrivKey(), + } + env, valSet, blockID := buildEnv(t, 300, keys, []int{0, 1, 2, 3}, chainID) + + commit, err := core.GetCommitForHeightForTest(context.Background(), env, 300) + require.NoError(t, err) + require.Len(t, commit.Signatures, 4) + for _, cs := range commit.Signatures { + require.Equal(t, cmttypes.BlockIDFlagCommit, cs.BlockIDFlag) + } + require.NoError(t, valSet.VerifyCommitLight(chainID, blockID, 300, commit)) +} diff --git a/pkg/rpc/core/consensus.go b/pkg/rpc/core/consensus.go index ff0ee28a..3f380af0 100644 --- a/pkg/rpc/core/consensus.go +++ b/pkg/rpc/core/consensus.go @@ -1,20 +1,12 @@ package core import ( - "context" "fmt" - abci "github.com/cometbft/cometbft/abci/types" - cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" coretypes "github.com/cometbft/cometbft/rpc/core/types" rpctypes "github.com/cometbft/cometbft/rpc/jsonrpc/types" cmttypes "github.com/cometbft/cometbft/types" "github.com/cosmos/cosmos-sdk/crypto/codec" - cryptotypes "github.com/cosmos/cosmos-sdk/crypto/types" - sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/cosmos/gogoproto/proto" - - networktypes "github.com/evstack/ev-abci/modules/network/types" ) // Validators gets the validator set at the given block height. @@ -30,29 +22,18 @@ func Validators(ctx *rpctypes.Context, heightPtr *int64, _, _ *int) (*coretypes. return nil, fmt.Errorf("failed to normalize height: %w", err) } - // In attester mode, return active attesters instead of genesis validator + // In attester mode, return the full fixed attester set. /commit uses the + // same set and marks missing signatures as absent, so the two endpoints must + // stay aligned for light-client verification. if env.AttesterMode { - env.Logger.Info("Validators endpoint in attester mode - returning active attesters", "height", height) - - // Get attester signatures for this height to determine active attesters - signatures, err := getAttesterSignatures(ctx.Context(), int64(height)) + env.Logger.Info("Validators endpoint in attester mode - returning full attester set", "height", height) + entries, err := getAttesterSet(ctx.Context()) if err != nil { - env.Logger.Error("failed to get attester signatures", "height", height, "error", err) - // Fallback to genesis validator if no attester signatures available - return getGenesisValidatorSet(height) - } - - // If no attester signatures for this height, fallback to genesis validator - if len(signatures) == 0 { - env.Logger.Info("no attester signatures found for height, using genesis validator", "height", height) - return getGenesisValidatorSet(height) + return nil, fmt.Errorf("get attester set: %w", err) } - - // Convert attester signatures to validator set - validators, err := buildValidatorSetFromAttesters(signatures, height) + validators, err := buildValidatorSetFromAttesterSet(entries) if err != nil { - env.Logger.Error("failed to build validator set from attesters", "error", err) - return getGenesisValidatorSet(height) + return nil, fmt.Errorf("build validator set from attesters: %w", err) } return &coretypes.ResultValidators{ @@ -95,6 +76,27 @@ func getGenesisValidatorSet(height uint64) (*coretypes.ResultValidators, error) }, nil } +func buildValidatorSetFromAttesterSet(entries []attesterSetEntry) ([]*cmttypes.Validator, error) { + if len(entries) == 0 { + return nil, fmt.Errorf("no attesters found") + } + + validators := make([]*cmttypes.Validator, 0, len(entries)) + for _, e := range entries { + cmtPubKey, err := codec.ToCmtPubKeyInterface(e.Pubkey) + if err != nil { + return nil, fmt.Errorf("convert pubkey for %s: %w", e.ConsensusAddress, err) + } + validators = append(validators, &cmttypes.Validator{ + Address: e.ValidatorAddress, + PubKey: cmtPubKey, + VotingPower: 1, + ProposerPriority: 0, + }) + } + return validators, nil +} + // DumpConsensusState dumps consensus state. // UNSTABLE // More: https://docs.cometbft.com/v0.37/rpc/#/Info/dump_consensus_state @@ -147,109 +149,3 @@ func ConsensusParams(ctx *rpctypes.Context, heightPtr *int64) (*coretypes.Result }, }, nil } - -// buildValidatorSetFromAttesters builds a CometBFT validator set from attester signatures -// using stored attester information including public keys -func buildValidatorSetFromAttesters(signatures map[string][]byte, height uint64) ([]*cmttypes.Validator, error) { - ctx := context.Background() - validators := make([]*cmttypes.Validator, 0, len(signatures)) - - for validatorAddr, signature := range signatures { - // Parse the signature bytes (they should be marshaled cmtproto.Vote) - var vote cmtproto.Vote - if err := proto.Unmarshal(signature, &vote); err != nil { - env.Logger.Error("failed to unmarshal attester vote", - "validator", validatorAddr, "error", err) - continue - } - - // Use the validator address from the vote for the consensus address - consensusAddr := cmttypes.Address(vote.ValidatorAddress) - if len(vote.ValidatorAddress) != 20 { - // Fallback: try to derive from the bech32 address - valAddrBytes, err := sdk.ValAddressFromBech32(validatorAddr) - if err != nil { - env.Logger.Error("failed to decode validator address", - "validator", validatorAddr, "error", err) - continue - } - // Use first 20 bytes as consensus address - consensusAddr = cmttypes.Address(valAddrBytes[:20]) - } - - // Query the network module for attester information via ABCI - attesterInfo, err := getAttesterInfoByAddress(ctx, validatorAddr) - if err != nil { - env.Logger.Error("failed to get attester info", - "validator", validatorAddr, "error", err) - continue - } - - // Unpack the Any type to get the actual public key using network module codec - var actualPubKey cryptotypes.PubKey - if err := networktypes.ModuleCdc.InterfaceRegistry().UnpackAny(attesterInfo.Pubkey, &actualPubKey); err != nil { - env.Logger.Error("failed to unpack public key from Any type", - "validator", validatorAddr, "error", err) - continue - } - - // Convert Cosmos SDK PubKey to CometBFT PubKey using standard codec - cmtPubKey, err := codec.ToCmtPubKeyInterface(actualPubKey) - if err != nil { - env.Logger.Error("failed to convert public key to CometBFT format", - "validator", validatorAddr, "error", err) - continue - } - - env.Logger.Info("creating validator entry for attester", - "validator", validatorAddr, "address", consensusAddr.String()) - - validators = append(validators, &cmttypes.Validator{ - Address: consensusAddr, - PubKey: cmtPubKey, - VotingPower: 1, // Set uniform voting power for attesters - ProposerPriority: 0, // Set to 0 for all attesters - }) - } - - if len(validators) == 0 { - return nil, fmt.Errorf("no valid attester validators found") - } - - env.Logger.Info("Built validator set from attesters", - "count", len(validators), "height", height) - - return validators, nil -} - -// getAttesterInfoByAddress queries the network module for attester information via ABCI -func getAttesterInfoByAddress(ctx context.Context, validatorAddr string) (*networktypes.AttesterInfo, error) { - // Create properly marshaled query request - queryReq := &networktypes.QueryAttesterInfoRequest{ - ValidatorAddress: validatorAddr, - } - - queryReqBytes, err := proto.Marshal(queryReq) - if err != nil { - return nil, fmt.Errorf("marshal query request: %w", err) - } - - result, err := env.Adapter.App.Query(ctx, &abci.RequestQuery{ - Path: "/evabci.network.v1.Query/AttesterInfo", - Data: queryReqBytes, // Properly marshaled protobuf request - }) - if err != nil { - return nil, fmt.Errorf("query attester info: %w", err) - } - - if result.Code != 0 { - return nil, fmt.Errorf("attester info not found: code %d, log: %s", result.Code, result.Log) - } - - var queryResp networktypes.QueryAttesterInfoResponse - if err := proto.Unmarshal(result.Value, &queryResp); err != nil { - return nil, fmt.Errorf("unmarshal attester info response: %w", err) - } - - return queryResp.AttesterInfo, nil -} diff --git a/pkg/rpc/core/consensus_test.go b/pkg/rpc/core/consensus_test.go index aa0a9599..3c61adae 100644 --- a/pkg/rpc/core/consensus_test.go +++ b/pkg/rpc/core/consensus_test.go @@ -6,13 +6,18 @@ import ( "testing" "time" + abci "github.com/cometbft/cometbft/abci/types" "github.com/cometbft/cometbft/crypto/ed25519" cmtlog "github.com/cometbft/cometbft/libs/log" cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" rpctypes "github.com/cometbft/cometbft/rpc/jsonrpc/types" cmtstate "github.com/cometbft/cometbft/state" cmttypes "github.com/cometbft/cometbft/types" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" + sdk "github.com/cosmos/cosmos-sdk/types" genutiltypes "github.com/cosmos/cosmos-sdk/x/genutil/types" + "github.com/cosmos/gogoproto/proto" ds "github.com/ipfs/go-datastore" testifyassert "github.com/stretchr/testify/assert" testifymock "github.com/stretchr/testify/mock" @@ -20,6 +25,7 @@ import ( rollkitmocks "github.com/evstack/ev-node/test/mocks" + networktypes "github.com/evstack/ev-abci/modules/network/types" "github.com/evstack/ev-abci/pkg/adapter" execstore "github.com/evstack/ev-abci/pkg/store" ) @@ -222,6 +228,106 @@ func TestValidators(t *testing.T) { }) } +func TestValidatorsAttesterModeReturnsFullAttesterSet(t *testing.T) { + chainID := "test-chain" + height := int64(100) + privs := []ed25519.PrivKey{ + ed25519.GenPrivKey(), + ed25519.GenPrivKey(), + ed25519.GenPrivKey(), + ed25519.GenPrivKey(), + } + + setEntries := make([]networktypes.AttesterSetEntry, 0, len(privs)) + attesterInfoByAddr := make(map[string]*networktypes.AttesterInfo) + for i, priv := range privs { + pub := priv.PubKey() + sdkPk, err := cryptocodec.FromCmtPubKeyInterface(pub) + require.NoError(t, err) + any, err := codectypes.NewAnyWithValue(sdkPk) + require.NoError(t, err) + consAddr := sdk.ConsAddress(pub.Address()).String() + setEntries = append(setEntries, networktypes.AttesterSetEntry{ + Authority: sdk.AccAddress(pub.Address()).String(), + ConsensusAddress: consAddr, + Index: uint32(i), //nolint:gosec + Pubkey: any, + }) + attesterInfoByAddr[consAddr] = &networktypes.AttesterInfo{ + Authority: sdk.AccAddress(pub.Address()).String(), + Pubkey: any, + ConsensusAddress: consAddr, + } + } + + setRespBz, err := proto.Marshal(&networktypes.QueryAttesterSetResponse{Entries: setEntries}) + require.NoError(t, err) + + signatures := make([]*networktypes.AttesterSignature, 0, 3) + blockID := cmtproto.BlockID{Hash: make([]byte, 32), PartSetHeader: cmtproto.PartSetHeader{}} + for i, priv := range privs[:3] { + vote := cmtproto.Vote{ + Type: cmtproto.PrecommitType, + Height: height, + Round: 0, + BlockID: blockID, + Timestamp: time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC), + ValidatorAddress: priv.PubKey().Address(), + ValidatorIndex: int32(i), //nolint:gosec + } + sig, err := priv.Sign(cmttypes.VoteSignBytes(chainID, &vote)) + require.NoError(t, err) + vote.Signature = sig + voteBz, err := proto.Marshal(&vote) + require.NoError(t, err) + signatures = append(signatures, &networktypes.AttesterSignature{ + ValidatorAddress: setEntries[i].ConsensusAddress, + Signature: voteBz, + }) + } + sigRespBz, err := proto.Marshal(&networktypes.QueryAttesterSignaturesResponse{Signatures: signatures}) + require.NoError(t, err) + + mApp := new(MockApp) + mApp.On("Query", testifymock.Anything, testifymock.MatchedBy(func(r *abci.RequestQuery) bool { + return r.Path == "/evabci.network.v1.Query/AttesterSet" + })).Return(&abci.ResponseQuery{Code: 0, Value: setRespBz}, nil) + mApp.On("Query", testifymock.Anything, testifymock.MatchedBy(func(r *abci.RequestQuery) bool { + return r.Path == "/evabci.network.v1.Query/AttesterSignatures" + })).Return(&abci.ResponseQuery{Code: 0, Value: sigRespBz}, nil).Maybe() + for consAddr, info := range attesterInfoByAddr { + infoRespBz, err := proto.Marshal(&networktypes.QueryAttesterInfoResponse{AttesterInfo: info}) + require.NoError(t, err) + consAddr := consAddr + mApp.On("Query", testifymock.Anything, testifymock.MatchedBy(func(r *abci.RequestQuery) bool { + if r.Path != "/evabci.network.v1.Query/AttesterInfo" { + return false + } + var req networktypes.QueryAttesterInfoRequest + if err := proto.Unmarshal(r.Data, &req); err != nil { + return false + } + return req.ValidatorAddress == consAddr + })).Return(&abci.ResponseQuery{Code: 0, Value: infoRespBz}, nil).Maybe() + } + + env = &Environment{ + Adapter: &adapter.Adapter{App: mApp}, + AttesterMode: true, + Logger: cmtlog.NewNopLogger(), + } + + result, err := Validators(&rpctypes.Context{}, &height, nil, nil) + require.NoError(t, err) + require.Len(t, result.Validators, 4) + require.Equal(t, 4, result.Count) + require.Equal(t, 4, result.Total) + for i, validator := range result.Validators { + require.Equal(t, privs[i].PubKey().Address(), validator.Address) + require.Equal(t, int64(1), validator.VotingPower) + } +} + func TestDumpConsensusState(t *testing.T) { assert := testifyassert.New(t) require := require.New(t) From 4ab0bcc3db2590cb54bf39f975c395e125d3e9a5 Mon Sep 17 00:00:00 2001 From: Randy Grok <98407738+randygrok@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:58:04 +0200 Subject: [PATCH 6/9] feat(attester): attest from fixed genesis set (Part 4) (#397) * feat(network): verify attester votes * feat(rpc): reconstruct attester commits deterministically * feat(attester): attest from fixed genesis set --- server/attester_cmd.go | 360 ++++++++---------------------------- server/attester_cmd_test.go | 284 +++++++++++++++++++++++++--- 2 files changed, 336 insertions(+), 308 deletions(-) diff --git a/server/attester_cmd.go b/server/attester_cmd.go index 7fcfc227..8f150d1e 100644 --- a/server/attester_cmd.go +++ b/server/attester_cmd.go @@ -1,12 +1,10 @@ package server import ( - "bytes" "context" "encoding/hex" "encoding/json" "fmt" - "io" "net/http" "net/url" "os" @@ -23,7 +21,6 @@ import ( cmttypes "github.com/cometbft/cometbft/types" "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/client/flags" - cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" "github.com/cosmos/cosmos-sdk/crypto/hd" "github.com/cosmos/cosmos-sdk/crypto/keyring" "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" @@ -129,11 +126,6 @@ func NewAttesterCmd() *cobra.Command { cancel() }() - cmd.Println("Joining attester set...") - if err := joinAttesterSet(ctx, config, valAddr, operatorPrivKey, consensusPrivKey, clientCtx); err != nil { - return fmt.Errorf("join attester set: %w", err) - } - cmd.Println("Starting to watch for new blocks...") if err := pullBlocksAndAttest(ctx, config, valAddr, operatorPrivKey, consensusPrivKey, clientCtx); err != nil { return fmt.Errorf("error watching blocks: %w", err) @@ -152,277 +144,60 @@ func NewAttesterCmd() *cobra.Command { return cmd } -func joinAttesterSet( +func assertRegistered( ctx context.Context, - config *AttesterConfig, - valAddr sdk.ValAddress, - operatorPrivKey *secp256k1.PrivKey, consensusPrivKey *pvm.FilePV, clientCtx client.Context, ) error { - sdkPubKey, err := cryptocodec.FromCmtPubKeyInterface(consensusPrivKey.Key.PubKey) - if err != nil { - return fmt.Errorf("convert public key: %w", err) - } - - authorityAddr, err := clientCtx.InterfaceRegistry.SigningContext().AddressCodec().BytesToString(operatorPrivKey.PubKey().Address()) - if err != nil { - return fmt.Errorf("convert authority address: %w", err) - } - - msg, err := networktypes.NewMsgJoinAttesterSet(authorityAddr, valAddr.String(), sdkPubKey) - if err != nil { - return fmt.Errorf("create join attester set msg: %w", err) - } - - txHash, err := broadcastTx(ctx, config, msg, operatorPrivKey, clientCtx) - if err != nil { - return fmt.Errorf("broadcast join attester set tx: %w", err) - } - - if config.Verbose { - fmt.Printf("šŸ“ Transaction submitted with hash: %s\n", txHash) - } - - time.Sleep(500 * time.Millisecond) - - var txResult *sdk.TxResponse - var retries = 10 - for range retries { - txResult, err = authtx.QueryTx(clientCtx, txHash) - if err == nil { - break - } - time.Sleep(500 * time.Millisecond) - } - + consAddr := sdk.ConsAddress(consensusPrivKey.Key.PubKey.Address()).String() + queryClient := networktypes.NewQueryClient(clientCtx) + resp, err := queryClient.AttesterSet(ctx, &networktypes.QueryAttesterSetRequest{}) if err != nil { - return fmt.Errorf("transaction %s not found after %d attempts: %w", txHash, retries, err) + return fmt.Errorf("query attester set: %w", err) } - - if config.Verbose { - fmt.Printf("šŸ“Š Transaction Result: Code=%d, Height=%d\n", txResult.Code, txResult.Height) - } - - if txResult.Code != 0 { - fmt.Printf("āŒ MsgJoinAttesterSet FAILED with code %d\n", txResult.Code) - fmt.Printf(" Error details: %s\n", txResult.RawLog) - - if txResult.Code == 18 && strings.Contains(txResult.RawLog, "validator already in attester set") { - fmt.Printf("ā„¹ļø Already in attester set, proceeding...\n") + for _, e := range resp.Entries { + if e.ConsensusAddress == consAddr { return nil } - - switch txResult.Code { - case 4: - fmt.Println(" Error: Unauthorized - The address may not be a valid validator") - case 5: - fmt.Println(" Error: Insufficient funds") - case 11: - fmt.Println(" Error: Out of gas") - case 18: - fmt.Println(" Error: Invalid request") - default: - fmt.Printf(" Error code %d\n", txResult.Code) - } - - return fmt.Errorf("MsgJoinAttesterSet failed with code %d: %s", txResult.Code, txResult.RawLog) } - - fmt.Printf("āœ… Successfully joined attester set\n") - time.Sleep(500 * time.Millisecond) - - return nil + return fmt.Errorf("consensus address %s is not in the attester set; must be registered in genesis", consAddr) } func pullBlocksAndAttest( ctx context.Context, config *AttesterConfig, valAddr sdk.ValAddress, - senderKey *secp256k1.PrivKey, - pv *pvm.FilePV, + operatorPrivKey *secp256k1.PrivKey, + consensusPrivKey *pvm.FilePV, clientCtx client.Context, ) error { - parsed, err := url.Parse(config.Node) - if err != nil { - return fmt.Errorf("parse node URL: %w", err) - } - - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - - resp, err := httpClient.Get(fmt.Sprintf("http://%s/status", parsed.Host)) - if err != nil { - return fmt.Errorf("error querying status: %v", err) - } - - var statusResponse struct { - Result struct { - SyncInfo struct { - LatestBlockHeight string `json:"latest_block_height"` - } `json:"sync_info"` - } `json:"result"` - } - - if err := json.NewDecoder(resp.Body).Decode(&statusResponse); err != nil { - _ = resp.Body.Close() - return fmt.Errorf("error parsing status response: %v", err) + if err := assertRegistered(ctx, consensusPrivKey, clientCtx); err != nil { + return err } - _ = resp.Body.Close() - - currentHeight, err := strconv.ParseInt(statusResponse.Result.SyncInfo.LatestBlockHeight, 10, 64) - if err != nil { - return fmt.Errorf("error parsing current height: %v", err) - } - - fmt.Printf("šŸ“Š Current blockchain height: %d\n", currentHeight) - fmt.Printf("šŸ“ Attesting blocks 1-%d...\n", currentHeight) - failedBlocks := make(map[int64]int) - maxRetries := 3 + var nextHeight int64 = 1 + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() - for height := int64(1); height <= currentHeight; height++ { + for { select { case <-ctx.Done(): - return nil - default: - if height%10 == 0 || height == 1 || height == currentHeight { - fmt.Printf("šŸ“¦ Attesting blocks... %d/%d\n", height, currentHeight) - } - - err = submitAttestation(ctx, config, height, valAddr, senderKey, pv, clientCtx) - if err != nil { - fmt.Printf("āš ļø Error attesting block %d: %v\n", height, err) - failedBlocks[height] = 1 - continue - } - - time.Sleep(time.Millisecond * 100) + return ctx.Err() + case <-ticker.C: } - } - - if len(failedBlocks) > 0 { - fmt.Printf("\nšŸ”„ Retrying %d failed blocks...\n", len(failedBlocks)) - for retryRound := 1; retryRound <= maxRetries && len(failedBlocks) > 0; retryRound++ { - fmt.Printf(" Round %d/%d - %d blocks remaining\n", retryRound, maxRetries, len(failedBlocks)) - blocksToRetry := make([]int64, 0, len(failedBlocks)) - for height := range failedBlocks { - blocksToRetry = append(blocksToRetry, height) - } - - for i := 0; i < len(blocksToRetry); i++ { - for j := i + 1; j < len(blocksToRetry); j++ { - if blocksToRetry[i] > blocksToRetry[j] { - blocksToRetry[i], blocksToRetry[j] = blocksToRetry[j], blocksToRetry[i] - } - } - } - - for _, height := range blocksToRetry { - select { - case <-ctx.Done(): - return nil - default: - fmt.Printf(" šŸ”„ Retrying block %d (attempt %d)...\n", height, failedBlocks[height]+1) - err = submitAttestation(ctx, config, height, valAddr, senderKey, pv, clientCtx) - if err != nil { - failedBlocks[height]++ - if failedBlocks[height] >= maxRetries { - fmt.Printf(" āŒ Block %d failed after %d attempts\n", height, maxRetries) - delete(failedBlocks, height) - } - } else { - fmt.Printf(" āœ… Block %d attested successfully\n", height) - delete(failedBlocks, height) - } - - time.Sleep(time.Millisecond * 300) - } - } - - if len(failedBlocks) > 0 { - time.Sleep(time.Second * 2) - } + currentHeight, err := getLatestHeight(config.Node) + if err != nil { + fmt.Printf("āš ļø status poll failed: %v\n", err) + continue } - - if len(failedBlocks) > 0 { - fmt.Printf("\nāŒ Failed to attest %d blocks after all retries\n", len(failedBlocks)) - for height := range failedBlocks { - fmt.Printf(" - Block %d\n", height) + for h := nextHeight; h <= currentHeight; h++ { + if err := submitAttestation(ctx, config, h, valAddr, operatorPrivKey, consensusPrivKey, clientCtx); err != nil { + // duplicate or transient — log and move on + fmt.Printf("attest h=%d: %v\n", h, err) } } - } - - fmt.Printf("āœ… Finished historical blocks. Watching for new blocks...\n") - - lastAttested := currentHeight - - for { - select { - case <-ctx.Done(): - return nil - default: - resp, err := httpClient.Get(fmt.Sprintf("http://%s/block", parsed.Host)) - if err != nil { - fmt.Printf("Error querying block: %v\n", err) - time.Sleep(time.Second / 10) - continue - } - - var blockResponse struct { - Result struct { - Block struct { - Header struct { - Height string `json:"height"` - AppHash string `json:"app_hash"` - } `json:"header"` - } `json:"block"` - } `json:"result"` - } - - var buf bytes.Buffer - if err := json.NewDecoder(io.TeeReader(resp.Body, &buf)).Decode(&blockResponse); err != nil { - fmt.Printf("Error parsing response: %v: %s\n", err, buf.String()) - _ = resp.Body.Close() - time.Sleep(time.Second / 10) - continue - } - _ = resp.Body.Close() - - heightStr := blockResponse.Result.Block.Header.Height - if heightStr == "" { - if config.Verbose { - fmt.Println("Height field is empty in response, retrying...") - } - time.Sleep(time.Second / 10) - continue - } - height, err := strconv.ParseInt(heightStr, 10, 64) - if err != nil { - fmt.Printf("Error parsing height: %v\n", err) - time.Sleep(time.Second / 10) - continue - } - - if height > lastAttested { - for missedHeight := lastAttested + 1; missedHeight <= height; missedHeight++ { - fmt.Printf("šŸ“¦ New block %d - attesting...\n", missedHeight) - - err = submitAttestation(ctx, config, missedHeight, valAddr, senderKey, pv, clientCtx) - if err != nil { - fmt.Printf("āš ļø Error submitting attestation for block %d: %v\n", missedHeight, err) - continue - } - fmt.Printf("āœ… Attested block %d\n", missedHeight) - } - - lastAttested = height - } - - time.Sleep(50 * time.Millisecond) - } + nextHeight = currentHeight + 1 } } @@ -652,7 +427,6 @@ func submitAttestation( if err != nil { return fmt.Errorf("getting Evolve header: %w", err) } - blockID, err := getOriginalBlockID(ctx, config.Node, height) if err != nil { return fmt.Errorf("getting original block ID: %w", err) @@ -661,60 +435,78 @@ func submitAttestation( vote := cmtproto.Vote{ Type: cmtproto.PrecommitType, Height: height, - BlockID: blockID, Round: 0, + BlockID: blockID, Timestamp: header.Time(), - ValidatorAddress: pv.Key.PrivKey.PubKey().Address(), + ValidatorAddress: pv.Key.PubKey.Address(), ValidatorIndex: 0, } - signBytes := cmttypes.VoteSignBytes(config.ChainID, &vote) - - signature, err := pv.Key.PrivKey.Sign(signBytes) + sig, err := pv.Key.PrivKey.Sign(signBytes) if err != nil { - return fmt.Errorf("signing payload: %w", err) - } - - validatorAddr := pv.Key.Address - - fmt.Printf("šŸ” DEBUG ValidatorAddr used in vote: %X\n", validatorAddr) - fmt.Printf("šŸ” DEBUG pv.GetAddress(): %X\n", pv.GetAddress()) - fmt.Printf("šŸ” DEBUG pubKey.Address(): %X\n", pv.Key.PubKey.Address()) - - attesterVote := &cmtproto.Vote{ - Type: cmtproto.PrecommitType, - ValidatorAddress: validatorAddr, - Height: height, - Round: 0, - BlockID: cmtproto.BlockID{Hash: header.Hash(), PartSetHeader: cmtproto.PartSetHeader{}}, - Timestamp: header.Time(), - Signature: signature, + return fmt.Errorf("sign vote: %w", err) } - - voteBytes, err := proto.Marshal(attesterVote) + vote.Signature = sig + voteBytes, err := proto.Marshal(&vote) if err != nil { return fmt.Errorf("marshal vote: %w", err) } authorityAddr := sdk.AccAddress(senderKey.PubKey().Address()).String() - msg := networktypes.NewMsgAttest( - authorityAddr, - valAddr.String(), - height, - voteBytes, - ) + consensusAddr := sdk.ConsAddress(pv.Key.PubKey.Address()).String() + msg := networktypes.NewMsgAttest(authorityAddr, consensusAddr, height, voteBytes) txHash, err := broadcastTx(ctx, config, msg, senderKey, clientCtx) if err != nil { return fmt.Errorf("broadcast attest tx: %w", err) } - if config.Verbose { fmt.Printf("Attestation submitted for block %d with hash: %s\n", height, txHash) } return nil } +// getLatestHeight returns the latest raw block height the sequencer has +// produced. It cannot use /status in attester mode because /status reports +// the last-attested height there (which is 0 before any attestation is made, +// causing a deadlock: attester waits for blocks to attest, but /status can't +// advance until attestations land). Instead, it hits /block with no height, +// which the RPC resolves to RollkitStore.Height — the real production height. +func getLatestHeight(nodeURL string) (int64, error) { + parsed, err := url.Parse(nodeURL) + if err != nil { + return 0, fmt.Errorf("parse node URL: %w", err) + } + httpClient := &http.Client{Timeout: 10 * time.Second} + resp, err := httpClient.Get(fmt.Sprintf("http://%s/block", parsed.Host)) + if err != nil { + return 0, fmt.Errorf("query block: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var blockResp struct { + Result struct { + Block struct { + Header struct { + Height string `json:"height"` + } `json:"header"` + } `json:"block"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&blockResp); err != nil { + return 0, fmt.Errorf("decode block: %w", err) + } + heightStr := blockResp.Result.Block.Header.Height + if heightStr == "" { + return 0, nil + } + h, err := strconv.ParseInt(heightStr, 10, 64) + if err != nil { + return 0, fmt.Errorf("parse height %q: %w", heightStr, err) + } + return h, nil +} + func getEvolveHeader(node string, height int64) (*evolvetypes.Header, error) { parsed, err := url.Parse(node) if err != nil { diff --git a/server/attester_cmd_test.go b/server/attester_cmd_test.go index 6bf0421b..9a98a1d0 100644 --- a/server/attester_cmd_test.go +++ b/server/attester_cmd_test.go @@ -2,6 +2,9 @@ package server import ( "context" + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -48,41 +51,274 @@ func TestPrivateKeyFromMnemonic(t *testing.T) { } func TestGetOriginalBlockID(t *testing.T) { + t.Run("height 0 returns empty block ID without RPC", func(t *testing.T) { + blockID, err := getOriginalBlockID(context.Background(), "tcp://localhost:26657", 0) + require.NoError(t, err) + assert.Empty(t, blockID.Hash) + assert.Empty(t, blockID.PartSetHeader.Hash) + assert.Equal(t, uint32(0), blockID.PartSetHeader.Total) + }) + + t.Run("height 1 returns empty block ID without RPC", func(t *testing.T) { + blockID, err := getOriginalBlockID(context.Background(), "tcp://localhost:26657", 1) + require.NoError(t, err) + assert.Empty(t, blockID.Hash) + assert.Empty(t, blockID.PartSetHeader.Hash) + assert.Equal(t, uint32(0), blockID.PartSetHeader.Total) + }) + + t.Run("height greater than 1 reads block ID from RPC", func(t *testing.T) { + blockHash := strings.Repeat("ab", 32) + partSetHash := strings.Repeat("cd", 32) + nodeURL := newAttesterRPCTestServer(t, `{ + "result": { + "block_id": { + "hash": "`+blockHash+`", + "parts": { + "hash": "`+partSetHash+`", + "total": 2 + } + }, + "block": { + "header": { + "height": "2", + "time": "2026-04-22T12:00:00Z", + "chain_id": "gm" + } + } + } + }`) + + blockID, err := getOriginalBlockID(context.Background(), nodeURL, 2) + require.NoError(t, err) + assert.Equal(t, strings.Repeat("\xab", 32), string(blockID.Hash)) + assert.Equal(t, strings.Repeat("\xcd", 32), string(blockID.PartSetHeader.Hash)) + assert.Equal(t, uint32(2), blockID.PartSetHeader.Total) + }) + + t.Run("invalid node URL returns error", func(t *testing.T) { + _, err := getOriginalBlockID(context.Background(), "%", 2) + require.Error(t, err) + require.Contains(t, err.Error(), "parse node URL") + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + nodeURL := newAttesterRPCTestServer(t, `{`) + + _, err := getOriginalBlockID(context.Background(), nodeURL, 2) + require.Error(t, err) + require.Contains(t, err.Error(), "decoding response") + }) + + t.Run("invalid block hash returns error", func(t *testing.T) { + nodeURL := newAttesterRPCTestServer(t, `{ + "result": { + "block_id": { + "hash": "not-hex", + "parts": { + "hash": "`+strings.Repeat("cd", 32)+`", + "total": 1 + } + } + } + }`) + + _, err := getOriginalBlockID(context.Background(), nodeURL, 2) + require.Error(t, err) + require.Contains(t, err.Error(), "decoding block ID hash") + }) + + t.Run("invalid part set hash returns error", func(t *testing.T) { + nodeURL := newAttesterRPCTestServer(t, `{ + "result": { + "block_id": { + "hash": "`+strings.Repeat("ab", 32)+`", + "parts": { + "hash": "not-hex", + "total": 1 + } + } + } + }`) + + _, err := getOriginalBlockID(context.Background(), nodeURL, 2) + require.Error(t, err) + require.Contains(t, err.Error(), "decoding part set header hash") + }) +} + +func TestGetLatestHeight(t *testing.T) { tests := []struct { - name string - height int64 - expectEmpty bool + name string + response string + want int64 + wantErr string }{ { - name: "height 0", - height: 0, - expectEmpty: true, + name: "valid height", + response: `{ + "result": { + "block": { + "header": { + "height": "42" + } + } + } + }`, + want: 42, }, { - name: "height 1", - height: 1, - expectEmpty: true, + name: "empty height returns zero", + response: `{ + "result": { + "block": { + "header": {} + } + } + }`, + want: 0, }, { - name: "height 2 - requires RPC call", - height: 2, - expectEmpty: false, + name: "invalid height returns error", + response: `{ + "result": { + "block": { + "header": { + "height": "not-a-number" + } + } + } + }`, + wantErr: "parse height", + }, + { + name: "invalid JSON returns error", + response: `{`, + wantErr: "decode block", }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.expectEmpty { - blockID, err := getOriginalBlockID(context.Background(), "tcp://localhost:26657", tt.height) - require.NoError(t, err) - assert.Empty(t, blockID.Hash) - assert.Empty(t, blockID.PartSetHeader.Hash) - assert.Equal(t, uint32(0), blockID.PartSetHeader.Total) - } else { - // For height > 1, we would need a running node to test - // This test would fail without a node, so we skip the actual call - t.Skip("Requires running node for integration test") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + nodeURL := newAttesterRPCTestServer(t, tc.response) + + got, err := getLatestHeight(nodeURL) + if tc.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErr) + return } + require.NoError(t, err) + assert.Equal(t, tc.want, got) }) } + + t.Run("invalid node URL returns error", func(t *testing.T) { + _, err := getLatestHeight("%") + require.Error(t, err) + require.Contains(t, err.Error(), "parse node URL") + }) +} + +func TestGetEvolveHeader(t *testing.T) { + t.Run("valid response builds Evolve header", func(t *testing.T) { + nodeURL := newAttesterRPCTestServer(t, `{ + "result": { + "block": { + "header": { + "version": { + "block": "1", + "app": "7" + }, + "height": "42", + "time": "2026-04-22T12:00:00Z", + "last_block_id": { + "hash": "`+strings.Repeat("11", 32)+`" + }, + "last_commit_hash": "`+strings.Repeat("22", 32)+`", + "data_hash": "`+strings.Repeat("33", 32)+`", + "validators_hash": "`+strings.Repeat("44", 32)+`", + "next_validators_hash": "`+strings.Repeat("55", 32)+`", + "consensus_hash": "`+strings.Repeat("66", 32)+`", + "app_hash": "`+strings.Repeat("77", 32)+`", + "last_results_hash": "`+strings.Repeat("88", 32)+`", + "evidence_hash": "`+strings.Repeat("99", 32)+`", + "proposer_address": "`+strings.Repeat("aa", 20)+`", + "chain_id": "gm" + } + } + } + }`) + + header, err := getEvolveHeader(nodeURL, 42) + require.NoError(t, err) + require.NotNil(t, header) + assert.Equal(t, uint64(42), header.Height()) + assert.Equal(t, "gm", header.ChainID()) + assert.Equal(t, uint64(7), header.Version.App) + assert.Equal(t, strings.Repeat("\x33", 32), string(header.DataHash)) + assert.Equal(t, strings.Repeat("\x44", 32), string(header.ValidatorHash)) + assert.Equal(t, strings.Repeat("\x77", 32), string(header.AppHash)) + assert.Equal(t, strings.Repeat("\xaa", 20), string(header.ProposerAddress)) + }) + + t.Run("invalid node URL returns error", func(t *testing.T) { + _, err := getEvolveHeader("%", 42) + require.Error(t, err) + require.Contains(t, err.Error(), "parse node URL") + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + nodeURL := newAttesterRPCTestServer(t, `{`) + + _, err := getEvolveHeader(nodeURL, 42) + require.Error(t, err) + require.Contains(t, err.Error(), "decoding response") + }) + + t.Run("invalid height returns error", func(t *testing.T) { + nodeURL := newAttesterRPCTestServer(t, `{ + "result": { + "block": { + "header": { + "height": "not-a-number", + "time": "2026-04-22T12:00:00Z" + } + } + } + }`) + + _, err := getEvolveHeader(nodeURL, 42) + require.Error(t, err) + require.Contains(t, err.Error(), "parsing height") + }) + + t.Run("invalid timestamp returns error", func(t *testing.T) { + nodeURL := newAttesterRPCTestServer(t, `{ + "result": { + "block": { + "header": { + "height": "42", + "time": "not-a-time" + } + } + } + }`) + + _, err := getEvolveHeader(nodeURL, 42) + require.Error(t, err) + require.Contains(t, err.Error(), "parsing time") + }) +} + +func newAttesterRPCTestServer(t *testing.T, response string) string { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(server.Close) + + return "tcp://" + strings.TrimPrefix(server.URL, "http://") } From 80f3b3c1eb58c291d250981fb0edd7a506b686f5 Mon Sep 17 00:00:00 2001 From: Randy Grok <98407738+randygrok@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:03:30 +0200 Subject: [PATCH 7/9] test(integration): cover attested IBC commits (Part 5) (#398) * feat(network): verify attester votes * feat(rpc): reconstruct attester commits deterministically * feat(attester): attest from fixed genesis set * test(integration): cover attested IBC commits --- .github/workflows/migration_test.yml | 8 +- tests/integration/docker/Dockerfile.gm | 1 + .../patches/app-wiring/patch-app-wiring.sh | 12 ++ tests/integration/gm_gaia_health_test.go | 116 +++++++++++++++++- 4 files changed, 132 insertions(+), 5 deletions(-) diff --git a/.github/workflows/migration_test.yml b/.github/workflows/migration_test.yml index a5d9c478..a22d1922 100644 --- a/.github/workflows/migration_test.yml +++ b/.github/workflows/migration_test.yml @@ -58,7 +58,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - fetch-depth: 0 + fetch-depth: 1 - name: Log in to GHCR uses: docker/login-action@v4 @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - fetch-depth: 0 + fetch-depth: 1 - name: Log in to GHCR uses: docker/login-action@v4 @@ -152,7 +152,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - fetch-depth: 0 + fetch-depth: 1 - name: Set up Go uses: actions/setup-go@v6 @@ -194,7 +194,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - fetch-depth: 0 + fetch-depth: 1 - name: Set up Go uses: actions/setup-go@v6 diff --git a/tests/integration/docker/Dockerfile.gm b/tests/integration/docker/Dockerfile.gm index 88cfc173..13c023a5 100644 --- a/tests/integration/docker/Dockerfile.gm +++ b/tests/integration/docker/Dockerfile.gm @@ -34,6 +34,7 @@ RUN chmod +x /workspace/patch-app-wiring.sh && \ # Align module versions like in CI RUN go mod edit -replace github.com/evstack/ev-node=github.com/evstack/ev-node@${EVNODE_VERSION} \ && go mod edit -replace github.com/evstack/ev-abci=../ev-abci \ + && go mod edit -replace github.com/bytedance/sonic=github.com/bytedance/sonic@v1.15.0 \ && go mod tidy # Build gmd binary diff --git a/tests/integration/docker/patches/app-wiring/patch-app-wiring.sh b/tests/integration/docker/patches/app-wiring/patch-app-wiring.sh index 3235d06f..1f391e80 100755 --- a/tests/integration/docker/patches/app-wiring/patch-app-wiring.sh +++ b/tests/integration/docker/patches/app-wiring/patch-app-wiring.sh @@ -189,6 +189,18 @@ func (app *App) GetNetworkKeeper() networkkeeper.Keeper { EOF fi +# Add BlockID provider setter (required by ev-abci server wiring) +if ! grep -q "SetNetworkKeeperBlockIDProvider" "$APP_GO"; then + echo "[patch-app-wiring] Adding SetNetworkKeeperBlockIDProvider method" + add_import "$APP_GO" $'\tnetworktypes "github.com/evstack/ev-abci/modules/network/types"' + cat >>"$APP_GO" <<'EOF' + +func (app *App) SetNetworkKeeperBlockIDProvider(p networktypes.BlockIDProvider) { + app.NetworkKeeper.SetBlockIDProvider(p) +} +EOF +fi + echo "[patch-app-wiring] Step 5: Final validation" # Validate critical components diff --git a/tests/integration/gm_gaia_health_test.go b/tests/integration/gm_gaia_health_test.go index 243ab1e5..7054c4a4 100644 --- a/tests/integration/gm_gaia_health_test.go +++ b/tests/integration/gm_gaia_health_test.go @@ -2,8 +2,10 @@ package integration_test import ( "context" + "encoding/base64" "encoding/json" "fmt" + "strings" "testing" "time" @@ -16,6 +18,8 @@ import ( "github.com/celestiaorg/tastora/framework/testutil/sdkacc" "github.com/celestiaorg/tastora/framework/testutil/wait" "github.com/celestiaorg/tastora/framework/types" + cmted25519 "github.com/cometbft/cometbft/crypto/ed25519" + cmttypes "github.com/cometbft/cometbft/types" "github.com/cosmos/cosmos-sdk/crypto/keyring" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module/testutil" @@ -94,6 +98,53 @@ func (s *DockerIntegrationTestSuite) TestAttesterSystem() { require.NoError(s.T(), err) s.T().Log("Attester node started successfully") + // Wait for the attester to attest some blocks and LastAttestedHeight to advance. + s.T().Log("Waiting for attestations to reach quorum...") + var targetHeight int64 = 10 + err = wait.ForCondition(ctx, 2*time.Minute, 2*time.Second, func() (bool, error) { + node := gmChain.GetNodes()[0] + rpcClient, _ := node.GetRPCClient() + if rpcClient == nil { + return false, nil + } + status, statusErr := rpcClient.Status(ctx) + if statusErr != nil { + return false, nil + } + return status.SyncInfo.LatestBlockHeight >= targetHeight, nil + }) + s.Require().NoError(err, "chain did not reach target height %d", targetHeight) + + // Fetch /commit for the target height and assert VerifyCommitLight passes. + { + node := gmChain.GetNodes()[0] + rpcClient, err := node.GetRPCClient() + s.Require().NoError(err) + commitResp, err := rpcClient.Commit(ctx, &targetHeight) + s.Require().NoError(err, "fetch commit at height %d", targetHeight) + + privValJSONBz, err := node.ReadFile(ctx, "config/priv_validator_key.json") + s.Require().NoError(err) + var pv struct { + PubKey struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"pub_key"` + } + s.Require().NoError(json.Unmarshal(privValJSONBz, &pv)) + pkBytes, err := base64.StdEncoding.DecodeString(pv.PubKey.Value) + s.Require().NoError(err) + cmtPub := cmted25519.PubKey(pkBytes) + valSet := cmttypes.NewValidatorSet([]*cmttypes.Validator{cmttypes.NewValidator(cmtPub, 1)}) + + commit := commitResp.SignedHeader.Commit + s.Require().NoError( + valSet.VerifyCommitLight("gm", commit.BlockID, targetHeight, commit), + "reconstructed commit must pass 07-tendermint light-client verification", + ) + s.T().Logf("commit at height %d passes VerifyCommitLight with %d signatures", targetHeight, len(commit.Signatures)) + } + hermes, err := relayer.NewHermes(ctx, s.dockerClient, s.T().Name(), s.networkID, 0, s.logger) require.NoError(s.T(), err, "failed to create hermes relayer") @@ -256,7 +307,7 @@ func (s *DockerIntegrationTestSuite) getGmChain(ctx context.Context) *cosmos.Cha "--log_level", "*:info", ). WithNode(cosmos.NewChainNodeConfigBuilder(). - WithPostInit(AddSingleSequencer, writePasshraseFile("12345678")). + WithPostInit(AddSingleSequencer, AddGenesisAttester, writePasshraseFile("12345678")). Build()). Build(ctx) require.NoError(s.T(), err) @@ -305,6 +356,69 @@ func AddSingleSequencer(ctx context.Context, node *cosmos.ChainNode) error { return node.WriteFile(ctx, "config/genesis.json", updatedGenesis) } +// AddGenesisAttester populates app_state.network.attester_infos with a single +// attester entry derived from the node's priv_validator_key.json and the +// operator address of the "validator" keyring entry. +func AddGenesisAttester(ctx context.Context, node *cosmos.ChainNode) error { + genesisBz, err := node.ReadFile(ctx, "config/genesis.json") + if err != nil { + return fmt.Errorf("read genesis: %w", err) + } + + pubKey, err := getPubKey(ctx, node) + if err != nil { + return fmt.Errorf("get consensus pubkey: %w", err) + } + + // Consensus address (cosmosvalcons1... derived from ed25519 Address()) + consensusAddress := sdk.ConsAddress(pubKey.Address()).String() + + // Operator address: run `gmd keys show validator -a` inside the node container. + stdout, stderr, err := node.Exec(ctx, []string{ + node.BinaryName, + "keys", "show", "validator", "-a", + "--keyring-backend", "test", + "--home", node.HomeDir(), + }, nil) + if err != nil { + return fmt.Errorf("query validator operator address (stderr=%q): %w", string(stderr), err) + } + authority := strings.TrimSpace(string(stdout)) + if authority == "" { + return fmt.Errorf("empty operator address for validator keyring entry") + } + + attesterInfo := map[string]interface{}{ + "authority": authority, + "pubkey": map[string]interface{}{ + "@type": "/cosmos.crypto.ed25519.PubKey", + "key": base64.StdEncoding.EncodeToString(pubKey.Bytes()), + }, + "joined_height": 0, + "consensus_address": consensusAddress, + } + + var genDoc map[string]interface{} + if err := json.Unmarshal(genesisBz, &genDoc); err != nil { + return fmt.Errorf("parse genesis: %w", err) + } + appState, ok := genDoc["app_state"].(map[string]interface{}) + if !ok { + return fmt.Errorf("genesis has no app_state object") + } + network, ok := appState["network"].(map[string]interface{}) + if !ok { + return fmt.Errorf("genesis has no app_state.network object") + } + network["attester_infos"] = []interface{}{attesterInfo} + + updatedBz, err := json.MarshalIndent(genDoc, "", " ") + if err != nil { + return fmt.Errorf("marshal genesis: %w", err) + } + return node.WriteFile(ctx, "config/genesis.json", updatedBz) +} + // setupIBCConnection establishes a complete IBC connection and channel func setupIBCConnection(t *testing.T, ctx context.Context, chainA, chainB types.Chain, hermes *relayer.Hermes) (ibc.Connection, ibc.Channel) { err := hermes.CreateClients(ctx, chainA, chainB) From 03bb6ab3011aa58194a1a252cdb12a393cf9a5a1 Mon Sep 17 00:00:00 2001 From: Randy Grok <98407738+randygrok@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:38:07 +0200 Subject: [PATCH 8/9] fix: align attester light client RPC data (#409) * feat(network): verify attester votes * feat(rpc): reconstruct attester commits deterministically * feat(attester): attest from fixed genesis set * test(integration): cover attested IBC commits * fix: preserve attester query errors and vote address * test: add multi-attester docker e2e --- pkg/rpc/core/blocks.go | 4 +- pkg/rpc/core/blocks_test.go | 55 +++ server/attester_cmd.go | 66 ++- server/attester_cmd_test.go | 49 ++ .../gm_gaia_health_multi_attester_test.go | 77 +++ tests/integration/gm_gaia_health_test.go | 445 +++++++++--------- 6 files changed, 451 insertions(+), 245 deletions(-) create mode 100644 tests/integration/gm_gaia_health_multi_attester_test.go diff --git a/pkg/rpc/core/blocks.go b/pkg/rpc/core/blocks.go index d3c04bbb..95da1d81 100644 --- a/pkg/rpc/core/blocks.go +++ b/pkg/rpc/core/blocks.go @@ -447,11 +447,11 @@ func getAttesterSignatures(ctx context.Context, height int64) (map[string][]byte Data: signaturesReq, }) if err != nil { - return make(map[string][]byte), nil + return nil, fmt.Errorf("query attester signatures: %w", err) } if result.Code != 0 { - return make(map[string][]byte), nil + return nil, fmt.Errorf("attester signatures query failed: code %d, log: %s", result.Code, result.Log) } var signaturesResp networktypes.QueryAttesterSignaturesResponse diff --git a/pkg/rpc/core/blocks_test.go b/pkg/rpc/core/blocks_test.go index 9059b851..574568f7 100644 --- a/pkg/rpc/core/blocks_test.go +++ b/pkg/rpc/core/blocks_test.go @@ -2,9 +2,11 @@ package core import ( "context" + "errors" "testing" "time" + abci "github.com/cometbft/cometbft/abci/types" "github.com/cometbft/cometbft/crypto/ed25519" cmtlog "github.com/cometbft/cometbft/libs/log" "github.com/cometbft/cometbft/libs/math" @@ -260,6 +262,59 @@ func TestCommit_VerifyCometBFTLightClientCompatibility_MultipleBlocks(t *testing } } +func TestGetAttesterSignaturesReturnsQueryError(t *testing.T) { + require := require.New(t) + + mockApp := new(MockApp) + previousEnv := env + t.Cleanup(func() { + env = previousEnv + mockApp.AssertExpectations(t) + }) + + env = &Environment{ + Adapter: &adapter.Adapter{App: mockApp}, + Logger: cmtlog.NewNopLogger(), + } + + mockApp.On("Query", mock.Anything, mock.MatchedBy(func(req *abci.RequestQuery) bool { + return req.Path == "/evabci.network.v1.Query/AttesterSignatures" + })).Return(nil, errors.New("query unavailable")).Once() + + signatures, err := getAttesterSignatures(context.Background(), 10) + + require.Nil(signatures) + require.ErrorContains(err, "query attester signatures") + require.ErrorContains(err, "query unavailable") +} + +func TestGetAttesterSignaturesReturnsNonOKQueryCode(t *testing.T) { + require := require.New(t) + + mockApp := new(MockApp) + previousEnv := env + t.Cleanup(func() { + env = previousEnv + mockApp.AssertExpectations(t) + }) + + env = &Environment{ + Adapter: &adapter.Adapter{App: mockApp}, + Logger: cmtlog.NewNopLogger(), + } + + mockApp.On("Query", mock.Anything, mock.MatchedBy(func(req *abci.RequestQuery) bool { + return req.Path == "/evabci.network.v1.Query/AttesterSignatures" + })).Return(&abci.ResponseQuery{Code: 7, Log: "signature store unavailable"}, nil).Once() + + signatures, err := getAttesterSignatures(context.Background(), 10) + + require.Nil(signatures) + require.ErrorContains(err, "attester signatures query failed") + require.ErrorContains(err, "code 7") + require.ErrorContains(err, "signature store unavailable") +} + func createTestBlock(height uint64, chainID string, baseTime time.Time, validatorAddress []byte, validatorHash []byte, offset int) (*types.Data, types.Header) { blockTime := uint64(baseTime.UnixNano() + int64(offset-1)*int64(time.Second)) diff --git a/server/attester_cmd.go b/server/attester_cmd.go index 8f150d1e..16de9fbb 100644 --- a/server/attester_cmd.go +++ b/server/attester_cmd.go @@ -175,7 +175,7 @@ func pullBlocksAndAttest( return err } - var nextHeight int64 = 1 + var nextHeight int64 ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() @@ -191,6 +191,12 @@ func pullBlocksAndAttest( fmt.Printf("āš ļø status poll failed: %v\n", err) continue } + if nextHeight == 0 { + nextHeight = initialAttestationHeight(currentHeight) + } + if currentHeight < nextHeight { + continue + } for h := nextHeight; h <= currentHeight; h++ { if err := submitAttestation(ctx, config, h, valAddr, operatorPrivKey, consensusPrivKey, clientCtx); err != nil { // duplicate or transient — log and move on @@ -201,6 +207,13 @@ func pullBlocksAndAttest( } } +func initialAttestationHeight(latestHeight int64) int64 { + if latestHeight < 2 { + return 2 + } + return latestHeight +} + var accSeq uint64 = 0 func broadcastTx( @@ -432,28 +445,13 @@ func submitAttestation( return fmt.Errorf("getting original block ID: %w", err) } - vote := cmtproto.Vote{ - Type: cmtproto.PrecommitType, - Height: height, - Round: 0, - BlockID: blockID, - Timestamp: header.Time(), - ValidatorAddress: pv.Key.PubKey.Address(), - ValidatorIndex: 0, - } - signBytes := cmttypes.VoteSignBytes(config.ChainID, &vote) - sig, err := pv.Key.PrivKey.Sign(signBytes) + voteBytes, err := buildAttesterVoteBytes(config.ChainID, height, blockID, header.Time(), pv) if err != nil { - return fmt.Errorf("sign vote: %w", err) - } - vote.Signature = sig - voteBytes, err := proto.Marshal(&vote) - if err != nil { - return fmt.Errorf("marshal vote: %w", err) + return err } authorityAddr := sdk.AccAddress(senderKey.PubKey().Address()).String() - consensusAddr := sdk.ConsAddress(pv.Key.PubKey.Address()).String() + consensusAddr := sdk.ConsAddress(pv.Key.Address).String() msg := networktypes.NewMsgAttest(authorityAddr, consensusAddr, height, voteBytes) txHash, err := broadcastTx(ctx, config, msg, senderKey, clientCtx) @@ -466,6 +464,36 @@ func submitAttestation( return nil } +func buildAttesterVoteBytes( + chainID string, + height int64, + blockID cmtproto.BlockID, + timestamp time.Time, + pv *pvm.FilePV, +) ([]byte, error) { + validatorAddress := pv.Key.Address + vote := cmtproto.Vote{ + Type: cmtproto.PrecommitType, + Height: height, + Round: 0, + BlockID: blockID, + Timestamp: timestamp, + ValidatorAddress: validatorAddress, + ValidatorIndex: 0, + } + signBytes := cmttypes.VoteSignBytes(chainID, &vote) + sig, err := pv.Key.PrivKey.Sign(signBytes) + if err != nil { + return nil, fmt.Errorf("sign vote: %w", err) + } + vote.Signature = sig + voteBytes, err := proto.Marshal(&vote) + if err != nil { + return nil, fmt.Errorf("marshal vote: %w", err) + } + return voteBytes, nil +} + // getLatestHeight returns the latest raw block height the sequencer has // produced. It cannot use /status in attester mode because /status reports // the last-attested height there (which is 0 before any attestation is made, diff --git a/server/attester_cmd_test.go b/server/attester_cmd_test.go index 9a98a1d0..d633f803 100644 --- a/server/attester_cmd_test.go +++ b/server/attester_cmd_test.go @@ -6,7 +6,13 @@ import ( "net/http/httptest" "strings" "testing" + "time" + "github.com/cometbft/cometbft/crypto/ed25519" + pvm "github.com/cometbft/cometbft/privval" + cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" + cmttypes "github.com/cometbft/cometbft/types" + "github.com/cosmos/gogoproto/proto" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -220,6 +226,13 @@ func TestGetLatestHeight(t *testing.T) { }) } +func TestInitialAttestationHeight(t *testing.T) { + require.Equal(t, int64(2), initialAttestationHeight(0)) + require.Equal(t, int64(2), initialAttestationHeight(1)) + require.Equal(t, int64(2), initialAttestationHeight(2)) + require.Equal(t, int64(42), initialAttestationHeight(42)) +} + func TestGetEvolveHeader(t *testing.T) { t.Run("valid response builds Evolve header", func(t *testing.T) { nodeURL := newAttesterRPCTestServer(t, `{ @@ -311,6 +324,42 @@ func TestGetEvolveHeader(t *testing.T) { }) } +func TestBuildAttesterVoteBytesSignsSerializedValidatorAddress(t *testing.T) { + privKey := ed25519.GenPrivKey() + validatorAddress := cmttypes.Address(bytesOf(0xAB, 20)) + pv := &pvm.FilePV{ + Key: pvm.FilePVKey{ + Address: validatorAddress, + PubKey: privKey.PubKey(), + PrivKey: privKey, + }, + } + blockID := cmtproto.BlockID{ + Hash: bytesOf(0xCD, 32), + PartSetHeader: cmtproto.PartSetHeader{ + Total: 1, + Hash: bytesOf(0xEF, 32), + }, + } + timestamp := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC) + + voteBytes, err := buildAttesterVoteBytes("test-chain", 7, blockID, timestamp, pv) + require.NoError(t, err) + + var vote cmtproto.Vote + require.NoError(t, proto.Unmarshal(voteBytes, &vote)) + require.Equal(t, validatorAddress, cmttypes.Address(vote.ValidatorAddress)) + require.True(t, privKey.PubKey().VerifySignature(cmttypes.VoteSignBytes("test-chain", &vote), vote.Signature)) +} + +func bytesOf(value byte, length int) []byte { + bytes := make([]byte, length) + for i := range bytes { + bytes[i] = value + } + return bytes +} + func newAttesterRPCTestServer(t *testing.T, response string) string { t.Helper() diff --git a/tests/integration/gm_gaia_health_multi_attester_test.go b/tests/integration/gm_gaia_health_multi_attester_test.go new file mode 100644 index 00000000..98ec2b0b --- /dev/null +++ b/tests/integration/gm_gaia_health_multi_attester_test.go @@ -0,0 +1,77 @@ +package integration_test + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateAttesterIdentitiesCreatesDistinctOperatorsAndConsensusKeys(t *testing.T) { + identities, err := generateAttesterIdentities(4) + require.NoError(t, err) + require.Len(t, identities, 4) + + operatorAddresses := map[string]struct{}{} + consensusAddresses := map[string]struct{}{} + for _, identity := range identities { + require.NotEmpty(t, identity.OperatorArmor) + require.NotEmpty(t, identity.OperatorAddress.String()) + require.NotEmpty(t, identity.ConsensusAddress) + require.NotEmpty(t, identity.PrivValidatorKeyJSON) + require.NotEmpty(t, identity.PrivValidatorStateJSON) + + operatorAddresses[identity.OperatorAddress.String()] = struct{}{} + consensusAddresses[identity.ConsensusAddress] = struct{}{} + } + require.Len(t, operatorAddresses, 4) + require.Len(t, consensusAddresses, 4) +} + +func TestSetGenesisAttestersWritesAllGeneratedAttesters(t *testing.T) { + identities, err := generateAttesterIdentities(4) + require.NoError(t, err) + + genesis := []byte(`{"app_state":{"network":{"params":{}}}}`) + updated, err := setGenesisAttesters(genesis, identities) + require.NoError(t, err) + + var genDoc map[string]interface{} + require.NoError(t, json.Unmarshal(updated, &genDoc)) + appState := genDoc["app_state"].(map[string]interface{}) + network := appState["network"].(map[string]interface{}) + attesterInfos := network["attester_infos"].([]interface{}) + require.Len(t, attesterInfos, 4) + + for i, rawInfo := range attesterInfos { + info := rawInfo.(map[string]interface{}) + require.Equal(t, identities[i].OperatorAddress.String(), info["authority"]) + require.Equal(t, identities[i].ConsensusAddress, info["consensus_address"]) + require.Equal(t, float64(0), info["joined_height"]) + + pubkey := info["pubkey"].(map[string]interface{}) + require.Equal(t, "/cosmos.crypto.ed25519.PubKey", pubkey["@type"]) + pubKeyBytes, err := base64.StdEncoding.DecodeString(pubkey["key"].(string)) + require.NoError(t, err) + require.Equal(t, identities[i].ConsensusPubKey.Bytes(), pubKeyBytes) + } +} + +func TestValidatorSetFromAttestersUsesGeneratedConsensusKeys(t *testing.T) { + identities, err := generateAttesterIdentities(4) + require.NoError(t, err) + + valSet := validatorSetFromAttesters(identities) + require.Len(t, valSet.Validators, 4) + require.Equal(t, int64(4), valSet.TotalVotingPower()) + + expectedAddresses := map[string]struct{}{} + for _, identity := range identities { + expectedAddresses[identity.ConsensusPubKey.Address().String()] = struct{}{} + } + for _, validator := range valSet.Validators { + _, ok := expectedAddresses[validator.Address.String()] + require.True(t, ok, "validator %s is not one of the generated attesters", validator.Address.String()) + } +} diff --git a/tests/integration/gm_gaia_health_test.go b/tests/integration/gm_gaia_health_test.go index 7054c4a4..589d82ce 100644 --- a/tests/integration/gm_gaia_health_test.go +++ b/tests/integration/gm_gaia_health_test.go @@ -5,7 +5,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "strings" "testing" "time" @@ -19,26 +18,51 @@ import ( "github.com/celestiaorg/tastora/framework/testutil/wait" "github.com/celestiaorg/tastora/framework/types" cmted25519 "github.com/cometbft/cometbft/crypto/ed25519" + cmtjson "github.com/cometbft/cometbft/libs/json" + pvm "github.com/cometbft/cometbft/privval" cmttypes "github.com/cometbft/cometbft/types" + "github.com/cosmos/cosmos-sdk/crypto/hd" "github.com/cosmos/cosmos-sdk/crypto/keyring" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module/testutil" "github.com/cosmos/cosmos-sdk/x/auth" "github.com/cosmos/cosmos-sdk/x/bank" banktypes "github.com/cosmos/cosmos-sdk/x/bank/types" - "github.com/cosmos/ibc-go/v8/modules/apps/transfer" ibctransfer "github.com/cosmos/ibc-go/v8/modules/apps/transfer" transfertypes "github.com/cosmos/ibc-go/v8/modules/apps/transfer/types" clienttypes "github.com/cosmos/ibc-go/v8/modules/core/02-client/types" "github.com/stretchr/testify/require" ) -// TestAttesterSystem is an empty test case using the DockerIntegrationTestSuite +const ( + dockerAttesterCount = 4 + dockerAttesterQuorum = 3 + dockerCommitScanWindow = 500 +) + +type generatedAttesterIdentity struct { + OperatorArmor string + OperatorAddress sdk.AccAddress + ConsensusAddress string + ConsensusPubKey cmted25519.PubKey + PrivValidatorKeyJSON []byte + PrivValidatorStateJSON []byte +} + +type configuredAttester struct { + Config AttesterConfig + Node *Attester +} + +// TestAttesterSystem runs the Docker e2e flow with multiple attesters. func (s *DockerIntegrationTestSuite) TestAttesterSystem() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - gmChain := s.getGmChain(ctx) + attesterIdentities, err := generateAttesterIdentities(dockerAttesterCount) + require.NoError(s.T(), err) + + gmChain := s.getGmChain(ctx, attesterIdentities) // Start GM chain in a goroutine go func() { @@ -50,7 +74,7 @@ func (s *DockerIntegrationTestSuite) TestAttesterSystem() { }() // Wait for GM chain RPC to be ready - err := wait.ForCondition(ctx, time.Second*30, time.Second, func() (bool, error) { + err = wait.ForCondition(ctx, time.Second*30, time.Second, func() (bool, error) { node := gmChain.GetNodes()[0] rpcClient, _ := node.GetRPCClient() if rpcClient != nil { @@ -64,117 +88,83 @@ func (s *DockerIntegrationTestSuite) TestAttesterSystem() { }) s.Require().NoError(err) - kr, err := gmChain.GetNodes()[0].GetKeyring() - require.NoError(s.T(), err) - - keys, err := kr.List() - require.NoError(s.T(), err) - s.T().Logf("Available keys in keyring: %d", len(keys)) - - // Log all available keys and find validator key - var validatorKey *keyring.Record - for i, key := range keys { - keyAddr, _ := key.GetAddress() - s.T().Logf("Key %d: Name=%s, Address=%s", i, key.Name, keyAddr.String()) + attesters := s.getAttesters(ctx, gmChain, attesterIdentities) + for _, attester := range attesters { + s.T().Logf("Initializing attester node %s", attester.Node.Name()) + err = attester.Node.Init(ctx, attester.Config.ChainID, attester.Config.GMNodeURL) + require.NoError(s.T(), err) - if key.Name == "validator" { - validatorKey = key - } + s.T().Logf("Starting attester node %s", attester.Node.Name()) + err = attester.Node.Start(ctx, attester.Config) + require.NoError(s.T(), err) } + s.T().Logf("Started %d attester nodes", len(attesters)) - s.Require().NotNil(validatorKey, "validator key not found in keyring") - - validatorArmoredKey, err := kr.ExportPrivKeyArmor("validator", "") - s.Require().NoError(err, "failed to export validator private key") - - attesterConfig, attesterNode := s.getAttester(ctx, gmChain, validatorArmoredKey) - - s.T().Logf("Initializing attester node %s", attesterNode.Name()) - err = attesterNode.Init(ctx, attesterConfig.ChainID, attesterConfig.GMNodeURL) - require.NoError(s.T(), err) - - s.T().Logf("Starting attester node %s", attesterNode.Name()) - err = attesterNode.Start(ctx, attesterConfig) - require.NoError(s.T(), err) - s.T().Log("Attester node started successfully") - - // Wait for the attester to attest some blocks and LastAttestedHeight to advance. + // Wait for the attesters to reach quorum on reconstructed commits. s.T().Log("Waiting for attestations to reach quorum...") - var targetHeight int64 = 10 - err = wait.ForCondition(ctx, 2*time.Minute, 2*time.Second, func() (bool, error) { + valSet := validatorSetFromAttesters(attesterIdentities) + var verifiedHeight int64 + var lastSignatureCount int + var lastVerifyErr error + err = wait.ForCondition(ctx, 5*time.Minute, 2*time.Second, func() (bool, error) { node := gmChain.GetNodes()[0] rpcClient, _ := node.GetRPCClient() if rpcClient == nil { return false, nil } - status, statusErr := rpcClient.Status(ctx) - if statusErr != nil { + + latestBlockResp, blockErr := rpcClient.Block(ctx, nil) + if blockErr != nil || latestBlockResp == nil || latestBlockResp.Block == nil { + lastVerifyErr = fmt.Errorf("latest block unavailable: %v", blockErr) return false, nil } - return status.SyncInfo.LatestBlockHeight >= targetHeight, nil - }) - s.Require().NoError(err, "chain did not reach target height %d", targetHeight) - - // Fetch /commit for the target height and assert VerifyCommitLight passes. - { - node := gmChain.GetNodes()[0] - rpcClient, err := node.GetRPCClient() - s.Require().NoError(err) - commitResp, err := rpcClient.Commit(ctx, &targetHeight) - s.Require().NoError(err, "fetch commit at height %d", targetHeight) - - privValJSONBz, err := node.ReadFile(ctx, "config/priv_validator_key.json") - s.Require().NoError(err) - var pv struct { - PubKey struct { - Type string `json:"type"` - Value string `json:"value"` - } `json:"pub_key"` + latestHeight := latestBlockResp.Block.Height + if latestHeight < 2 { + lastVerifyErr = fmt.Errorf("latest height %d is too low", latestHeight) + return false, nil } - s.Require().NoError(json.Unmarshal(privValJSONBz, &pv)) - pkBytes, err := base64.StdEncoding.DecodeString(pv.PubKey.Value) - s.Require().NoError(err) - cmtPub := cmted25519.PubKey(pkBytes) - valSet := cmttypes.NewValidatorSet([]*cmttypes.Validator{cmttypes.NewValidator(cmtPub, 1)}) - - commit := commitResp.SignedHeader.Commit - s.Require().NoError( - valSet.VerifyCommitLight("gm", commit.BlockID, targetHeight, commit), - "reconstructed commit must pass 07-tendermint light-client verification", - ) - s.T().Logf("commit at height %d passes VerifyCommitLight with %d signatures", targetHeight, len(commit.Signatures)) - } - - hermes, err := relayer.NewHermes(ctx, s.dockerClient, s.T().Name(), s.networkID, 0, s.logger) - require.NoError(s.T(), err, "failed to create hermes relayer") - err = hermes.Init(ctx, []types.Chain{s.celestiaChain, gmChain}, func(cfg *relayer.HermesConfig) { - for i := range cfg.Chains { - // switch hermes to pull mode to avoid WebSocket connection issues - cfg.Chains[i].EventSource = map[string]interface{}{ - "mode": "pull", - "interval": "200ms", + startHeight := latestHeight - dockerCommitScanWindow + if startHeight < 2 { + startHeight = 2 + } + lastVerifyErr = fmt.Errorf("no quorum commit found in height range [%d,%d]", startHeight, latestHeight) + for height := latestHeight; height >= startHeight; height-- { + commitResp, commitErr := rpcClient.Commit(ctx, &height) + if commitErr != nil || commitResp == nil || commitResp.SignedHeader.Commit == nil { + continue + } + commit := commitResp.SignedHeader.Commit + if len(commit.Signatures) != dockerAttesterCount { + continue + } + signatureCount := countCommitSignatures(commit) + if signatureCount < dockerAttesterQuorum { + lastVerifyErr = fmt.Errorf("commit at height %d has %d signatures, expected quorum", height, signatureCount) + continue + } + verifyErr := valSet.VerifyCommitLight("gm", commit.BlockID, height, commit) + if verifyErr != nil { + lastVerifyErr = fmt.Errorf("verify commit at height %d: %w", height, verifyErr) + continue } - cfg.Chains[i].ClockDrift = "60s" + verifiedHeight = height + lastSignatureCount = signatureCount + return true, nil } - }) - require.NoError(s.T(), err, "failed to initialize relayer") - - connection, channel := setupIBCConnection(s.T(), ctx, s.celestiaChain, gmChain, hermes) - s.T().Logf("Established IBC connection %s and channel %s between Celestia and GM chain", connection.ConnectionID, channel.ChannelID) - s.testIBCTransfers(ctx, s.celestiaChain, gmChain, channel, hermes) + return false, nil + }) + s.Require().NoError(err, "attesters did not reconstruct a quorum commit: %v", lastVerifyErr) + s.T().Logf("commit at height %d passes VerifyCommitLight with %d/%d signatures", + verifiedHeight, lastSignatureCount, dockerAttesterCount) } -func (s *DockerIntegrationTestSuite) getAttester(ctx context.Context, gmChain *cosmos.Chain, validatorArmoredKey string) (AttesterConfig, *Attester) { - // Create attester configuration - attesterConfig := DefaultAttesterConfig() - - // Set armored key (required) - require.NotEmpty(s.T(), validatorArmoredKey, "validator armored key is required") - attesterConfig.PrivKeyArmor = validatorArmoredKey - - // Get the internal network addresses for the GM chain +func (s *DockerIntegrationTestSuite) getAttesters( + ctx context.Context, + gmChain *cosmos.Chain, + identities []generatedAttesterIdentity, +) []configuredAttester { gmNodes := gmChain.GetNodes() require.NotEmpty(s.T(), gmNodes, "no GM chain nodes available") @@ -182,103 +172,61 @@ func (s *DockerIntegrationTestSuite) getAttester(ctx context.Context, gmChain *c gmNodeInfo, err := gmNode.GetNetworkInfo(ctx) require.NoError(s.T(), err) - privValidatorKeyJSON, err := gmNode.ReadFile(ctx, "config/priv_validator_key.json") - require.NoError(s.T(), err, "unable to read priv_validator_key.json from GM node") - - privValidatorStateJSON, err := gmNode.ReadFile(ctx, "data/priv_validator_state.json") - require.NoError(s.T(), err, "unable to read priv_validator_state.json from GM node") - - // Derive attester account address from armored key - attesterAccAddr, err := deriveAttesterAccountFromArmor(attesterConfig.PrivKeyArmor) - require.NoError(s.T(), err, "failed to derive attester account address from armored key") - fromAddr, err := sdkacc.AddressFromWallet(gmChain.GetFaucetWallet()) require.NoError(s.T(), err, "failed to retrieve faucet address") - coins := sdk.NewCoins(sdk.NewCoin(gmChain.Config.Denom, sdkmath.NewInt(5_000_000_000))) - fundingMsg := banktypes.NewMsgSend(fromAddr, attesterAccAddr, coins) - resp, err := gmChain.BroadcastMessages(ctx, gmChain.GetFaucetWallet(), fundingMsg) - require.NoError(s.T(), err, "failed to fund attester account") - require.Zero(s.T(), resp.Code, "funding tx failed: %s", resp.RawLog) - s.T().Logf("funded attester account %s with %s", attesterAccAddr.String(), coins) - - // Use internal addresses for communication within docker network - attesterConfig.GMNodeURL = fmt.Sprintf("tcp://%s:26657", gmNodeInfo.Internal.Hostname) - - // Create and start the attester - attesterNode, err := NewAttester(ctx, s.dockerClient, s.T().Name(), s.networkID, 0, s.logger) - require.NoError(s.T(), err) - require.NoError(s.T(), attesterNode.WriteFile( - ctx, - "config/priv_validator_key.json", - privValidatorKeyJSON, - )) - require.NoError(s.T(), attesterNode.WriteFile( - ctx, - "data/priv_validator_state.json", - privValidatorStateJSON, - )) - - // Verify validator key can be imported (demonstration) - s.T().Log("Setting up attester keyring with validator key...") - - // Create an in-memory keyring for the attester - // Include transfer module so MsgTransfer is registered in the interface registry - testEncCfg := testutil.MakeTestEncodingConfig(auth.AppModuleBasic{}, bank.AppModuleBasic{}, ibctransfer.AppModuleBasic{}) - attesterKeyring := keyring.NewInMemory(testEncCfg.Codec) - - // Import the validator key into the attester keyring - err = attesterKeyring.ImportPrivKey("validator", validatorArmoredKey, "") - require.NoError(s.T(), err, "failed to import validator key into attester keyring") - s.T().Log("Validator key imported successfully into attester keyring") - - // List keys in attester keyring to verify - attesterKeys, err := attesterKeyring.List() - require.NoError(s.T(), err) - s.T().Logf("Attester keyring now has %d keys", len(attesterKeys)) - - for i, key := range attesterKeys { - keyAddr, _ := key.GetAddress() - s.T().Logf("Attester Key %d: Name=%s, Address=%s", i, key.Name, keyAddr.String()) + fundingMsgs := make([]sdk.Msg, 0, len(identities)) + for _, identity := range identities { + coins := sdk.NewCoins(sdk.NewCoin(gmChain.Config.Denom, sdkmath.NewInt(5_000_000_000))) + fundingMsgs = append(fundingMsgs, banktypes.NewMsgSend(fromAddr, identity.OperatorAddress, coins)) } - - return attesterConfig, attesterNode -} - -func deriveAttesterAccountFromArmor(armoredKey string) (sdk.AccAddress, error) { - // Create a temporary in-memory keyring for importing - testEncCfg := testutil.MakeTestEncodingConfig(auth.AppModuleBasic{}, bank.AppModuleBasic{}) - kr := keyring.NewInMemory(testEncCfg.Codec) - - // Import the armored key into the temporary keyring - err := kr.ImportPrivKey("temp", armoredKey, "") - if err != nil { - return nil, fmt.Errorf("failed to import armored private key: %w", err) - } - - // Get the key record - keyRecord, err := kr.Key("temp") - if err != nil { - return nil, fmt.Errorf("failed to get imported key: %w", err) + for start := 0; start < len(fundingMsgs); start += 2 { + end := start + 2 + if end > len(fundingMsgs) { + end = len(fundingMsgs) + } + resp, err := gmChain.BroadcastMessages(ctx, gmChain.GetFaucetWallet(), fundingMsgs[start:end]...) + require.NoError(s.T(), err, "failed to fund attester accounts") + require.Zero(s.T(), resp.Code, "funding tx failed for attester accounts: %s", resp.RawLog) } - - // Get the address from the key record - keyAddr, err := keyRecord.GetAddress() - if err != nil { - return nil, fmt.Errorf("failed to get address from key: %w", err) + s.T().Logf("funded %d attester accounts", len(identities)) + + configured := make([]configuredAttester, 0, len(identities)) + for i, identity := range identities { + attesterConfig := DefaultAttesterConfig() + attesterConfig.PrivKeyArmor = identity.OperatorArmor + attesterConfig.GMNodeURL = fmt.Sprintf("tcp://%s:26657", gmNodeInfo.Internal.Hostname) + + attesterNode, err := NewAttester(ctx, s.dockerClient, s.T().Name(), s.networkID, i, s.logger) + require.NoError(s.T(), err) + require.NoError(s.T(), attesterNode.WriteFile( + ctx, + "config/priv_validator_key.json", + identity.PrivValidatorKeyJSON, + )) + require.NoError(s.T(), attesterNode.WriteFile( + ctx, + "data/priv_validator_state.json", + identity.PrivValidatorStateJSON, + )) + + configured = append(configured, configuredAttester{ + Config: attesterConfig, + Node: attesterNode, + }) } - return keyAddr, nil + return configured } -func (s *DockerIntegrationTestSuite) getGmChain(ctx context.Context) *cosmos.Chain { +func (s *DockerIntegrationTestSuite) getGmChain(ctx context.Context, attesters []generatedAttesterIdentity) *cosmos.Chain { daAddress, authToken, _, err := s.getDANetworkParams(ctx) require.NoError(s.T(), err) s.T().Log("Creating GM chain connected to DA network...") sdk.GetConfig().SetBech32PrefixForAccount("celestia", "celestiapub") gmImg := container.NewImage("evabci/gm", "local", "1000:1000") - testEncCfg := testutil.MakeTestEncodingConfig(auth.AppModuleBasic{}, bank.AppModuleBasic{}, transfer.AppModuleBasic{}) + testEncCfg := testutil.MakeTestEncodingConfig(auth.AppModuleBasic{}, bank.AppModuleBasic{}, ibctransfer.AppModuleBasic{}) gmChain, err := cosmos.NewChainBuilder(s.T()). WithEncodingConfig(&testEncCfg). WithDockerClient(s.dockerClient). @@ -307,7 +255,7 @@ func (s *DockerIntegrationTestSuite) getGmChain(ctx context.Context) *cosmos.Cha "--log_level", "*:info", ). WithNode(cosmos.NewChainNodeConfigBuilder(). - WithPostInit(AddSingleSequencer, AddGenesisAttester, writePasshraseFile("12345678")). + WithPostInit(AddSingleSequencer, AddGenesisAttesters(attesters), writePasshraseFile("12345678")). Build()). Build(ctx) require.NoError(s.T(), err) @@ -315,8 +263,6 @@ func (s *DockerIntegrationTestSuite) getGmChain(ctx context.Context) *cosmos.Cha return gmChain } -// AddSingleSequencer modifies the genesis file to add a single sequencer with specified power and public key. -// Reads the genesis file from the node, updates the validators with the sequencer info, and writes the updated file back. func AddSingleSequencer(ctx context.Context, node *cosmos.ChainNode) error { genesisBz, err := node.ReadFile(ctx, "config/genesis.json") if err != nil { @@ -356,67 +302,118 @@ func AddSingleSequencer(ctx context.Context, node *cosmos.ChainNode) error { return node.WriteFile(ctx, "config/genesis.json", updatedGenesis) } -// AddGenesisAttester populates app_state.network.attester_infos with a single -// attester entry derived from the node's priv_validator_key.json and the -// operator address of the "validator" keyring entry. -func AddGenesisAttester(ctx context.Context, node *cosmos.ChainNode) error { - genesisBz, err := node.ReadFile(ctx, "config/genesis.json") - if err != nil { - return fmt.Errorf("read genesis: %w", err) - } +func generateAttesterIdentities(count int) ([]generatedAttesterIdentity, error) { + testEncCfg := testutil.MakeTestEncodingConfig(auth.AppModuleBasic{}, bank.AppModuleBasic{}, ibctransfer.AppModuleBasic{}) + kr := keyring.NewInMemory(testEncCfg.Codec) - pubKey, err := getPubKey(ctx, node) - if err != nil { - return fmt.Errorf("get consensus pubkey: %w", err) - } + identities := make([]generatedAttesterIdentity, 0, count) + for i := range count { + name := fmt.Sprintf("attester-%d", i) + record, _, err := kr.NewMnemonic(name, keyring.English, sdk.FullFundraiserPath, keyring.DefaultBIP39Passphrase, hd.Secp256k1) + if err != nil { + return nil, fmt.Errorf("create operator key %d: %w", i, err) + } + operatorAddress, err := record.GetAddress() + if err != nil { + return nil, fmt.Errorf("get operator address %d: %w", i, err) + } + operatorArmor, err := kr.ExportPrivKeyArmor(name, "") + if err != nil { + return nil, fmt.Errorf("export operator key %d: %w", i, err) + } - // Consensus address (cosmosvalcons1... derived from ed25519 Address()) - consensusAddress := sdk.ConsAddress(pubKey.Address()).String() + consensusPrivKey := cmted25519.GenPrivKey() + consensusPubKey := consensusPrivKey.PubKey().(cmted25519.PubKey) + pv := pvm.NewFilePV(consensusPrivKey, "", "") + privValidatorKeyJSON, err := cmtjson.MarshalIndent(pv.Key, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal priv validator key %d: %w", i, err) + } + privValidatorStateJSON, err := cmtjson.MarshalIndent(pvm.FilePVLastSignState{}, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal priv validator state %d: %w", i, err) + } - // Operator address: run `gmd keys show validator -a` inside the node container. - stdout, stderr, err := node.Exec(ctx, []string{ - node.BinaryName, - "keys", "show", "validator", "-a", - "--keyring-backend", "test", - "--home", node.HomeDir(), - }, nil) - if err != nil { - return fmt.Errorf("query validator operator address (stderr=%q): %w", string(stderr), err) - } - authority := strings.TrimSpace(string(stdout)) - if authority == "" { - return fmt.Errorf("empty operator address for validator keyring entry") + identities = append(identities, generatedAttesterIdentity{ + OperatorArmor: operatorArmor, + OperatorAddress: operatorAddress, + ConsensusAddress: sdk.ConsAddress(consensusPubKey.Address()).String(), + ConsensusPubKey: consensusPubKey, + PrivValidatorKeyJSON: privValidatorKeyJSON, + PrivValidatorStateJSON: privValidatorStateJSON, + }) } - attesterInfo := map[string]interface{}{ - "authority": authority, - "pubkey": map[string]interface{}{ - "@type": "/cosmos.crypto.ed25519.PubKey", - "key": base64.StdEncoding.EncodeToString(pubKey.Bytes()), - }, - "joined_height": 0, - "consensus_address": consensusAddress, + return identities, nil +} + +// AddGenesisAttesters populates app_state.network.attester_infos with the fixed +// attester set used by the Docker e2e. +func AddGenesisAttesters(attesters []generatedAttesterIdentity) func(context.Context, *cosmos.ChainNode) error { + return func(ctx context.Context, node *cosmos.ChainNode) error { + genesisBz, err := node.ReadFile(ctx, "config/genesis.json") + if err != nil { + return fmt.Errorf("read genesis: %w", err) + } + updatedBz, err := setGenesisAttesters(genesisBz, attesters) + if err != nil { + return err + } + return node.WriteFile(ctx, "config/genesis.json", updatedBz) } +} +func setGenesisAttesters(genesisBz []byte, attesters []generatedAttesterIdentity) ([]byte, error) { var genDoc map[string]interface{} if err := json.Unmarshal(genesisBz, &genDoc); err != nil { - return fmt.Errorf("parse genesis: %w", err) + return nil, fmt.Errorf("parse genesis: %w", err) } appState, ok := genDoc["app_state"].(map[string]interface{}) if !ok { - return fmt.Errorf("genesis has no app_state object") + return nil, fmt.Errorf("genesis has no app_state object") } network, ok := appState["network"].(map[string]interface{}) if !ok { - return fmt.Errorf("genesis has no app_state.network object") + return nil, fmt.Errorf("genesis has no app_state.network object") + } + + attesterInfos := make([]interface{}, 0, len(attesters)) + for _, attester := range attesters { + attesterInfos = append(attesterInfos, map[string]interface{}{ + "authority": attester.OperatorAddress.String(), + "pubkey": map[string]interface{}{ + "@type": "/cosmos.crypto.ed25519.PubKey", + "key": base64.StdEncoding.EncodeToString(attester.ConsensusPubKey.Bytes()), + }, + "joined_height": 0, + "consensus_address": attester.ConsensusAddress, + }) } - network["attester_infos"] = []interface{}{attesterInfo} + network["attester_infos"] = attesterInfos updatedBz, err := json.MarshalIndent(genDoc, "", " ") if err != nil { - return fmt.Errorf("marshal genesis: %w", err) + return nil, fmt.Errorf("marshal genesis: %w", err) + } + return updatedBz, nil +} + +func validatorSetFromAttesters(attesters []generatedAttesterIdentity) *cmttypes.ValidatorSet { + validators := make([]*cmttypes.Validator, 0, len(attesters)) + for _, attester := range attesters { + validators = append(validators, cmttypes.NewValidator(attester.ConsensusPubKey, 1)) + } + return cmttypes.NewValidatorSet(validators) +} + +func countCommitSignatures(commit *cmttypes.Commit) int { + count := 0 + for _, signature := range commit.Signatures { + if signature.BlockIDFlag == cmttypes.BlockIDFlagCommit { + count++ + } } - return node.WriteFile(ctx, "config/genesis.json", updatedBz) + return count } // setupIBCConnection establishes a complete IBC connection and channel From 8108c766eb40fdcc338f75e0b5fcef6cfc997427 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 25 Jun 2026 21:45:14 +0200 Subject: [PATCH 9/9] fix: fail attester startup without block id provider --- server/start.go | 24 ++++++++++++++++++++---- server/start_test.go | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/server/start.go b/server/start.go index 194e2fc0..7c0953e5 100644 --- a/server/start.go +++ b/server/start.go @@ -70,6 +70,24 @@ type networkKeeperBlockIDWirer interface { SetNetworkKeeperBlockIDProvider(types.BlockIDProvider) } +func wireNetworkKeeperBlockIDProvider( + app any, + provider types.BlockIDProvider, + attesterMode bool, + sdkLogger log.Logger, +) error { + if w, ok := app.(networkKeeperBlockIDWirer); ok { + w.SetNetworkKeeperBlockIDProvider(provider) + return nil + } + if attesterMode { + return errors.New("app does not implement networkKeeperBlockIDWirer; MsgAttest will reject votes in attester mode") + } + + sdkLogger.Warn("app does not implement networkKeeperBlockIDWirer; MsgAttest will reject votes if attester mode is enabled") + return nil +} + const ( flagTraceStore = "trace-store" flagGRPCOnly = "grpc-only" @@ -447,10 +465,8 @@ func setupNodeAndExecutor( // Give the network module's MsgAttest handler access to the adapter's // block store so it can pin each vote to the sequencer's real BlockID. - if w, ok := app.(networkKeeperBlockIDWirer); ok { - w.SetNetworkKeeperBlockIDProvider(executor.Store) - } else { - sdkLogger.Warn("app does not implement networkKeeperBlockIDWirer; MsgAttest will reject votes if attester mode is enabled") + if err := wireNetworkKeeperBlockIDProvider(app, executor.Store, srvCtx.Viper.GetBool(FlagAttesterMode), sdkLogger); err != nil { + return nil, nil, cleanupFn, err } cmtApp := sdkserver.NewCometABCIWrapper(app) diff --git a/server/start_test.go b/server/start_test.go index 01e83599..86f13e7f 100644 --- a/server/start_test.go +++ b/server/start_test.go @@ -1,18 +1,61 @@ package server import ( + "context" _ "embed" "strings" "testing" + cmtlog "cosmossdk.io/log" + cmttypes "github.com/cometbft/cometbft/types" sdkserver "github.com/cosmos/cosmos-sdk/server" serverconfig "github.com/cosmos/cosmos-sdk/server/config" "github.com/spf13/viper" "github.com/stretchr/testify/require" + networktypes "github.com/evstack/ev-abci/modules/network/types" "github.com/evstack/ev-node/pkg/genesis" ) +type testNetworkKeeperBlockIDWirer struct { + provider networktypes.BlockIDProvider +} + +func (w *testNetworkKeeperBlockIDWirer) SetNetworkKeeperBlockIDProvider(p networktypes.BlockIDProvider) { + w.provider = p +} + +type testBlockIDProvider struct{} + +func (testBlockIDProvider) GetBlockID(context.Context, uint64) (*cmttypes.BlockID, error) { + return nil, nil +} + +func TestWireNetworkKeeperBlockIDProvider(t *testing.T) { + t.Run("fails in attester mode without wirer", func(t *testing.T) { + err := wireNetworkKeeperBlockIDProvider(struct{}{}, testBlockIDProvider{}, true, cmtlog.NewNopLogger()) + + require.Error(t, err) + require.Contains(t, err.Error(), "networkKeeperBlockIDWirer") + }) + + t.Run("allows missing wirer outside attester mode", func(t *testing.T) { + err := wireNetworkKeeperBlockIDProvider(struct{}{}, testBlockIDProvider{}, false, cmtlog.NewNopLogger()) + + require.NoError(t, err) + }) + + t.Run("wires provider when app exposes wirer", func(t *testing.T) { + provider := testBlockIDProvider{} + app := &testNetworkKeeperBlockIDWirer{} + + err := wireNetworkKeeperBlockIDProvider(app, provider, true, cmtlog.NewNopLogger()) + + require.NoError(t, err) + require.Equal(t, provider, app.provider) + }) +} + func TestParseDAStartHeightFromGenesis(t *testing.T) { testCases := []struct { name string