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 206d1163..aef544bb 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 @@ -327,28 +350,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 17143579..71f6f2fa 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,18 +48,12 @@ 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 { 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. params := k.GetParams(ctx) minHeight := int64(1) if params.PruneAfter > 0 && params.EpochLength > 0 { @@ -75,6 +65,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") @@ -84,51 +75,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) @@ -138,15 +117,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, @@ -155,7 +132,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 } @@ -185,6 +161,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)