diff --git a/internal/api/handlers/finality_provider.go b/internal/api/handlers/finality_provider.go index 2c0106cd..9b62dcd3 100644 --- a/internal/api/handlers/finality_provider.go +++ b/internal/api/handlers/finality_provider.go @@ -5,6 +5,7 @@ import ( "github.com/babylonlabs-io/staking-api-service/internal/services" "github.com/babylonlabs-io/staking-api-service/internal/types" + "github.com/babylonlabs-io/staking-api-service/internal/utils" ) // GetFinalityProviders gets active finality providers sorted by ActiveTvl. @@ -16,6 +17,9 @@ import ( // @Success 200 {object} PublicResponse[[]services.FpDetailsPublic] "A list of finality providers sorted by ActiveTvl in descending order" // @Router /v1/finality-providers [get] func (h *Handler) GetFinalityProviders(request *http.Request) (*Result, *types.Error) { + // Check if random sort is requested, defaulting to false if not specified + randomSort := request.URL.Query().Get("sort") == "random" + fpPk, err := parsePublicKeyQuery(request, "fp_btc_pk", true) if err != nil { return nil, err @@ -41,5 +45,10 @@ func (h *Handler) GetFinalityProviders(request *http.Request) (*Result, *types.E if err != nil { return nil, err } + + if randomSort { + fps = utils.Shuffle(fps) + } + return NewResultWithPagination(fps, paginationToken), nil } diff --git a/internal/db/stats.go b/internal/db/stats.go index 4063ddae..d490e511 100644 --- a/internal/db/stats.go +++ b/internal/db/stats.go @@ -15,6 +15,13 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) +const ( + // Hardcoded pagination limit of 500 for finality providers, this is to offload + // the sorting and searching to the frontend since number of providers is + // expected to be small + FinalityProviderPaginationLimit = 500 +) + // GetOrCreateStatsLock fetches the lock status for each stats type for the given staking tx hash. // If the document does not exist, it will create a new document with the default values // Refer to the README.md in this directory for more information on the stats lock @@ -285,7 +292,7 @@ func (db *Database) FindFinalityProviderStats(ctx context.Context, paginationTok } return findWithPagination( - ctx, client, filter, options, db.cfg.MaxPaginationLimit, + ctx, client, filter, options, FinalityProviderPaginationLimit, model.BuildFinalityProviderStatsPaginationToken, ) } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 1d63bae5..8b67ff36 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,6 +1,10 @@ package utils -import "encoding/json" +import ( + "encoding/json" + + "golang.org/x/exp/rand" +) // Contains checks if a slice contains a specific element. // It uses type parameters to work with any slice type. @@ -28,3 +32,13 @@ func DeepCopy(src, dst interface{}) error { return nil } + +// Shuffle randomly reorders the elements in a slice. +// It uses the Fisher-Yates shuffle algorithm. +func Shuffle[T any](slice []*T) []*T { + for i := len(slice) - 1; i > 0; i-- { + j := rand.Intn(i + 1) + slice[i], slice[j] = slice[j], slice[i] + } + return slice +} diff --git a/tests/integration_test/finality_provider_test.go b/tests/integration_test/finality_provider_test.go index 25fb1bb9..d1e4e7a1 100644 --- a/tests/integration_test/finality_provider_test.go +++ b/tests/integration_test/finality_provider_test.go @@ -7,10 +7,8 @@ import ( "math/rand" "net/http" "testing" - "time" "github.com/babylonlabs-io/staking-api-service/internal/api/handlers" - "github.com/babylonlabs-io/staking-api-service/internal/config" "github.com/babylonlabs-io/staking-api-service/internal/db" "github.com/babylonlabs-io/staking-api-service/internal/db/model" "github.com/babylonlabs-io/staking-api-service/internal/services" @@ -49,6 +47,60 @@ func TestGetFinalityProvidersSuccessfully(t *testing.T) { shouldGetFinalityProvidersSuccessfully(t, testServer) } +func TestGetFinalityProvidersShouldReturnRandomOrderWhenRandomSortRequested(t *testing.T) { + testServer := setupTestServer(t, nil) + url := testServer.Server.URL + finalityProvidersPath + "?sort=random" + defer testServer.Close() + + // Make multiple requests and verify the order changes while elements stay the same + var prevOrder []string + var expectedElements map[string]bool + foundDifferentOrder := false + + for i := 0; i < 5; i++ { + responseBody := fetchSuccessfulResponse[[]services.FpDetailsPublic](t, url) + result := responseBody.Data + + // Extract BtcPks to compare ordering + currentOrder := make([]string, len(result)) + currentElements := make(map[string]bool) + for j, fp := range result { + currentOrder[j] = fp.BtcPk + currentElements[fp.BtcPk] = true + } + + // Verify we still get all 4 FPs + assert.Equal(t, 4, len(result)) + + // Store expected elements on first iteration + if i == 0 { + prevOrder = currentOrder + expectedElements = currentElements + continue + } + + // Verify we get the same elements each time + assert.Equal(t, expectedElements, currentElements, "Elements returned should be the same across requests") + + // Check if order is different from previous + orderChanged := false + for j := 0; j < len(currentOrder); j++ { + if currentOrder[j] != prevOrder[j] { + orderChanged = true + break + } + } + + if orderChanged { + foundDifferentOrder = true + break + } + prevOrder = currentOrder + } + + assert.True(t, foundDifferentOrder, "Expected to find different ordering in multiple requests") +} + func TestGetFinalityProviderShouldNotFailInCaseOfDbFailure(t *testing.T) { mockDB := new(testmock.DBClient) mockDB.On("FindFinalityProviderStats", mock.Anything, mock.Anything).Return(nil, errors.New("just an error")) @@ -179,61 +231,6 @@ func FuzzGetFinalityProviderShouldReturnAllRegisteredFps(f *testing.F) { }) } -func FuzzTestGetFinalityProviderWithPaginationResponse(f *testing.F) { - attachRandomSeedsToFuzzer(f, 3) - f.Fuzz(func(t *testing.T, seed int64) { - r := rand.New(rand.NewSource(seed)) - opts := &testutils.TestActiveEventGeneratorOpts{ - NumOfEvents: 20, - FinalityProviders: testutils.GeneratePks(20), - Stakers: testutils.GeneratePks(20), - } - - activeStakingEvents := testutils.GenerateRandomActiveStakingEvents(r, opts) - cfg, err := config.New("../config/config-test.yml") - if err != nil { - t.Fatalf("Failed to load test config: %v", err) - } - cfg.Db.MaxPaginationLimit = 2 - - testServer := setupTestServer(t, &TestServerDependency{ConfigOverrides: cfg}) - defer testServer.Close() - sendTestMessage(testServer.Queues.ActiveStakingQueueClient, activeStakingEvents) - time.Sleep(10 * time.Second) - - var paginationKey string - var allDataCollected []services.FpDetailsPublic - var atLeastOnePage bool - // Test the API - for { - url := testServer.Server.URL + finalityProvidersPath + "?pagination_key=" + paginationKey - resp, err := http.Get(url) - assert.NoError(t, err, "making GET request to finality providers endpoint should not fail") - assert.Equal(t, http.StatusOK, resp.StatusCode, "expected HTTP 200 OK status") - bodyBytes, err := io.ReadAll(resp.Body) - assert.NoError(t, err, "reading response body should not fail") - var response handlers.PublicResponse[[]services.FpDetailsPublic] - err = json.Unmarshal(bodyBytes, &response) - assert.NoError(t, err, "unmarshalling response body should not fail") - - // Check that the response body is as expected - assert.NotEmptyf(t, response.Data, "expected response body to have data") - allDataCollected = append(allDataCollected, response.Data...) - if response.Pagination.NextKey != "" { - atLeastOnePage = true - paginationKey = response.Pagination.NextKey - } else { - break - } - } - - assert.True(t, atLeastOnePage, "expected at least one page") - for i := 0; i < len(allDataCollected)-1; i++ { - assert.True(t, allDataCollected[i].ActiveTvl >= allDataCollected[i+1].ActiveTvl) - } - }) -} - func FuzzGetFinalityProviderShouldNotReturnRegisteredFpWithoutStakingForPaginatedDbResponse(f *testing.F) { attachRandomSeedsToFuzzer(f, 100) f.Fuzz(func(t *testing.T, seed int64) {