diff --git a/modules/network/keeper/msg_server.go b/modules/network/keeper/msg_server.go index a33237ce..3faee4aa 100644 --- a/modules/network/keeper/msg_server.go +++ b/modules/network/keeper/msg_server.go @@ -51,8 +51,29 @@ 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) } - // todo (Alex): we need to set a limit to not have validators attest old blocks. Also make sure that this relates with - // the retention period for pruning + // Enforce attestation height upper bound to prevent storage exhaustion + // from future-height spam. + 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 { + currentEpoch := uint64(currentHeight) / params.EpochLength + if currentEpoch > params.PruneAfter { + minHeight = int64((currentEpoch - params.PruneAfter) * params.EpochLength) + } + } + 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") diff --git a/modules/network/keeper/msg_server_test.go b/modules/network/keeper/msg_server_test.go index d7a9cd6c..4b9623ad 100644 --- a/modules/network/keeper/msg_server_test.go +++ b/modules/network/keeper/msg_server_test.go @@ -225,6 +225,95 @@ func TestAttestVotePayloadValidation(t *testing.T) { } } +func TestAttestHeightBounds(t *testing.T) { + myValAddr := sdk.ValAddress("validator1") + // With DefaultParams: EpochLength=1, PruneAfter=15 + // At blockHeight=100: currentEpoch=100, minHeight=(100-7)*1=93 + + specs := map[string]struct { + blockHeight int64 + attestH int64 + expErr error + }{ + "future height rejected": { + blockHeight: 100, + attestH: 200, + expErr: sdkerrors.ErrInvalidRequest, + }, + "two-ahead rejected": { + blockHeight: 100, + attestH: 102, + expErr: sdkerrors.ErrInvalidRequest, + }, + "current height accepted": { + blockHeight: 100, + attestH: 100, + }, + "next height accepted": { + blockHeight: 100, + attestH: 101, + }, + "stale height rejected": { + blockHeight: 100, + attestH: 1, + expErr: sdkerrors.ErrInvalidRequest, + }, + "below retention window rejected": { + blockHeight: 100, + attestH: 84, // minHeight = 85 + expErr: sdkerrors.ErrInvalidRequest, + }, + "at retention boundary accepted": { + blockHeight: 100, + attestH: 93, // exactly minHeight + }, + "early chain no stale rejection": { + blockHeight: 16, + attestH: 1, + }, + } + + for name, spec := range specs { + t.Run(name, func(t *testing.T) { + 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()) + server := msgServer{Keeper: keeper} + ctx := sdk.NewContext(cms, cmtproto.Header{ + ChainID: "test-chain", + Time: time.Now().UTC(), + Height: spec.blockHeight, + }, false, logger).WithContext(t.Context()) + + require.NoError(t, keeper.SetParams(ctx, types.DefaultParams())) + + // Setup: add attester and build index map + require.NoError(t, keeper.SetAttesterSetMember(ctx, myValAddr.String())) + require.NoError(t, keeper.BuildValidatorIndexMap(ctx)) + + msg := &types.MsgAttest{ + Authority: myValAddr.String(), + ConsensusAddress: myValAddr.String(), + Height: spec.attestH, + Vote: make([]byte, MinVoteLen), + } + + rsp, err := server.Attest(ctx, msg) + if spec.expErr != nil { + require.ErrorIs(t, err, spec.expErr) + require.Nil(t, rsp) + return + } + require.NoError(t, err) + require.NotNil(t, rsp) + }) + } +} + var _ types.StakingKeeper = &MockStakingKeeper{} type MockStakingKeeper struct { diff --git a/modules/network/types/params.go b/modules/network/types/params.go index 811bf9e2..d97a34d7 100644 --- a/modules/network/types/params.go +++ b/modules/network/types/params.go @@ -11,7 +11,7 @@ var ( DefaultEpochLength = uint64(1) // Changed from 10 to 1 to allow attestations on every block DefaultQuorumFraction = math.LegacyNewDecWithPrec(667, 3) // 2/3 DefaultMinParticipation = math.LegacyNewDecWithPrec(5, 1) // 1/2 - DefaultPruneAfter = uint64(7) + DefaultPruneAfter = uint64(15) // also used as number of blocks for attestations to land DefaultSignMode = SignMode_SIGN_MODE_CHECKPOINT )